Imagine the following list:
Managing Director
Sales Director
IT Director
Technical Lead
Software Developer
Support Technician
HR Department
HR Officer
HR Assistant 1
HR Assistant 2
It's backed by a state in the form of:
[
{
id: 1,
text: "Managing Director",
children: [
{
id: 2,
text: "Sales Director"
}
...
]
}
...
]
Now I want to indent Support Technician. I would modify the state array to remove the item from the Technical Lead parent & add it to the Software Developer parent. The problem is, that React first deletes it, which causes all items below it to jump one line up, and then in the next frame adds it again to the new parent, which pushes those items a line down again. This appears as a flicker. It doesn't happen every time (sometimes react manages to render both in the same frame), but often enough it happens and is very distracting.
The state is modified in a way, that the parent passes its state callback setState down to its children. In this case, the initial state of the Technical Lead node looks like:
{
id: 4,
text: "Technical Lead",
children: [
{
id: 5,
text: "Software Developer"
},
{
id: 6,
text: "Support Technician"
}
]
}
As obvious from the state, every node renders all its children recursively.
After the indention, the state is modified to the following:
{
id: 4,
text: "Technical Lead",
children: [
{
id: 5,
text: "Software Developer",
chiilderen: [
{
id: 6,
text: "Support Technician"
}
]
}
]
}
If I were to this without React and instead with regular DOM APIs, I would move the node to the new parent with something like insertBefore(). React on the other hand unmounts & remounts the node.
Below is a simplified example of my "Node" component, which renders the list:
const Node = ({data, setSiblings}) => {
const [children, setChildren] = useState(data.children)
function indent() {
setSiblings(siblings => {
// const prevSibling = find the item in the state array
// const thisNode = {id, text, children}
const newPrevSibling = {...prevSibling, children: [thisNode]}
const siblingsWithout_ThisNode = deleteFromArray(siblings, thisNodeIndex)
// updateAtIndex() returns a new array with the modification (immutable)
return updateAtIndex(siblingsWithout_ThisNode, prevSiblingIndex, newPrevSibling)
})
}
const childNodes = children?.map(child =>
<Node data={child} setSiblings={setChildren} key={child.id}/>
)
return (
<li>
<div>{data.text}</div>
{childNodes ? <ul>{childNodes}</ul> : null}
</li>
)
}
The indent() function is triggered by a Tab press, but I didn't include the key handler logic here
I didn't find a solution to this problem directly, but I switched to using MST (MobX-State-Tree) for state management now and it worked with it (didn't flicker anymore - seemingly, both the unmounting & remounting of the component happen in the same frame now).
Below is a CodeSandbox with the MST implementation. When clicking e.g. on the Support Technician node, you can press Tab to indent and Shift + Tab to outdent (you have to click again, since it loses focus)
https://codesandbox.io/s/priceless-keldysh-17e9h?file=/src/App.js
While this doesn't answer my question directly, it helped solve the problem semantically (it's better than nothing).
Related
Background
I am building a single-page-application in React whose data will be retrieved from a relational database. Consider the following two tables:
menu
menu_items
A menu has many menu items. Menu items can be related to other menu items (represented in the database as an adjacency list). On the client, I'm representing it as a tree, i.e.:
{
"id": "menu",
"items": [
{
"id": "item-1",
"name": "Breakfast",
"children": []
},
{
"id": "item-2",
"name": "Lunch",
"children": [{ "id": "item-2-1", "children": [] }]
}
]
}
UI
A tree can get four levels deep and is typically much wider than it is tall. It is currently rendered recursively in the following way:
type Properties = {
items: {
id: string;
name: string;
children: Properties["items"];
}[];
};
const Items = ({ items }: Properties) => (
<ul>
{items.map((item) => (
<li key={item.id}>
{item.name}
<Items items={item.children} />
</li>
))}
</ul>
);
Problem
I have reached the stage where I want to update specific nodes in the tree. This operation seems complex, because it involves searching and replacing entire subtrees. Additionally, it will happen often, i.e. onChange, as a user updates item.name.
Although I don't use Redux, the following article explains it could be better to normalise nested client-side data to make operations like this easier: https://redux.js.org/usage/structuring-reducers/normalizing-state-shape.
Example
const menu = {
"id": "menu",
"itemMap": {
"item-1": { "parentId": null },
"item-2": { "parentId": null },
"item-2-1": { "parentId": "item-2" }
}
}
Question
Would I not have to denormalise/turn it back into a tree to render the UI? If yes, is there any point in my normalising the data?
I don't have a lot of experience with this and am struggling to find the right resources to answer the questions I have.
As with most engineering problems, there isn't a "correct" answer — rather it is a tradeoff: it depends on the expected use.
Your current approach optimizes for the maximum render performance at the cost of mutation performance. By using a tree structure, no transformation is needed at render time (just iteration) — however, arbitrary node lookups within the tree can't be done in constant time.
Another approach is to store the data as an associative array of nodes (Node ID ➡️ Node — e.g. Object/Map), which will optimize for arbitrary node lookup — and you can simply build the tree on every render by including each node's child IDs as part of its structure.
Here's an example of such a structure using the data that you provided:
TS Playground
<script src="https://cdn.jsdelivr.net/npm/#babel/standalone#7.20.15/babel.min.js"></script><script>Babel.registerPreset("tsx", {presets: [[Babel.availablePresets["typescript"], {allExtensions: true, isTSX: true}]]});</script>
<script type="text/babel" data-type="module" data-presets="tsx">
/** Any application-specific data type must satisfy this */
type ValidNode = {
/** Unique */
id: string;
/** Not allowed because of conflict */
children?: never;
};
// Your application-specific data type:
type Item = {
id: string;
name: string;
// etc.
};
type ListNode<T extends ValidNode> = T & {
/** List of child IDs */
children?: string[] | undefined;
};
// An object consisting of ID keys and ListNode values:
type ListNodeMap<T extends ValidNode> = Partial<Record<string, ListNode<T>>>;
const nodeMap: ListNodeMap<Item> = {
"menu": {
id: "menu",
name: "Menu",
children: ["item-1", "item-2"],
},
"item-1": {
id: "item-1",
name: "Breakfast",
},
"item-2": {
id: "item-2",
name: "Lunch",
children: ["item-2-1"],
},
"item-2-1": {
id: "item-2-1",
name: "Irresistibly healthy",
},
};
// A type-safe getter function which throws on bad IDs:
function getNode (
nodeMap: ListNodeMap<Item>,
id: string,
): ListNode<Item> {
const node = nodeMap[id];
if (!node) throw new Error(`Node ID ${JSON.stringify(id)} not found`);
return node;
}
/** The "linked" version of a ListNode */
type TreeNode<T extends ValidNode> = T & {
children?: T[] | undefined;
};
// Note: This uses recursion because it's for tree structures.
// Calling with list nodes having cyclic reference IDs will create an infinite loop.
function createTree (
nodeMap: ListNodeMap<Item>,
id: string,
): TreeNode<Item> {
const node = getNode(nodeMap, id);
return {
...node,
children: node.children?.map(id => createTree(nodeMap, id)),
};
}
console.log("node map:", nodeMap);
console.log("tree:", createTree(nodeMap, "menu"));
</script>
You didn't show how you receive the data, but if your API returns nodes with parent IDs instead of child IDs, then you can simply lookup each parent when acquiring new children and insert the child IDs at the time of acquisition — using an intermediate mapping structure if needed... that's tangential to the asked question.
You also didn't show how you plan to update node names, so I've excluded that part in the linked playground below, but here's an otherwise complete example of the code above with state and a reducer for updating an arbitrary node: Full example with state and reducer in TS Playground
So, I have a weird problem with Redux in my React app.
I am trying to create a notes app (how original) with Notion-style editor. In this editor, lines are like contenteditables with different stylings depending on line's tag (h1, h2, p and so on). Here is how notes are stored in Redux store:
notes: {
'uiwefniu1231': {
name: "Note 1",
parentId: 'root',
content: [
{tag: 'h1', text: "Header", id: '12312'},
{tag: 'h2', text: "Header 2", id: '121321'},
{tag: 'p', text: "Paragraph 1", id: '123s2'},
{tag: 'p', text: "Paragraph 2", id: '123s1'},
{tag: 'p', text: "Paragraph 3", id: '243143'},
{tag: 'p', text: "Paragraph 4", id: '52334'},
{tag: 'p', text: "Paragraph 5", id: '423223'},
]
},
}
I update note data with updateNote action dispatcher (I use Redux Toolkit):
updateNote: (state, action) => {
const {id, newData} = action.payload;
state.notes[id] = newData;
}
So, my Main component gets note id from URL and uses it to get opened note data with useSelector. That data is passed to Editor:
const Main = (props) => {
const {noteId} = useParams();
const noteData = useSelector(selectNote(noteId));
console.log(noteData.content)
return (
<main className={styles.main}>
<Header title={noteData.name}/>
<Editor data={noteData} id={noteId}/>
</main>
)
}
Editor gets note data, and maps through note's content array, rendering input component for each item. If you press Enter inside one of those blocks, you will get a new block underneath. This is where my problem starts.
My EditableBlock component (the one that is rendered for each block in the note content) calls addNewBlock function on Enter, this function gets current blocks array and adds new empty block to given index. But the blocks variable this function uses is not updated for some reason. Like, even if data is changed in Redux store, it always gets the initial state of blocks array, updates that copy of array and updates note content with that array, which causes bugs with editing.
I've spent a lot of time googling and just can't find the solution.
Here is the code for addNewBlock function btw:
const addNewBlock = (index, data) => {
debugger;
let newBlocks = [...blocks];
newBlocks.splice(index, 0, data);
dispatch(updateNote({
id: props.id,
newData: {
name: noteData.name,
parentId: noteData.parentId,
content: newBlocks,
}
}))
}
Here is the the demo where you can see this weird bug - https://notes-app-ecru-one.vercel.app/
Just choose some note from the sidebar and try to edit blocks.
And here is the repo: https://github.com/AzamatUmirzakov/notes-app
My situation is the following:
I have an array of game objects stored as an atom, each game in the array is of the same type and structure.
I have another atom which allows me to store the id of a game in the array that has been "targeted".
I have a selector which I can use to get the targeted game object by searching the array for a match between the game ids and the targeted game id I have stored.
Elsewhere in the application the game is rendered as a DOM element and calculations are made which I want to use to update the data in the game object in the global state.
It's this last step that's throwing me off. Should my selector be writable so I can update the game object? How do I do this?
This is a rough outline of the code I have:
export const gamesAtom = atom<GameData[]>({
key: 'games',
default: [
{
id: 1,
name: 'Bingo',
difficulty: 'easy',
},
{
id: 21,
name: 'Yahtzee',
difficulty: 'moderate',
},
{
id: 3,
name: 'Twister',
difficulty: 'hard',
},
],
});
export const targetGameIdAtom = atom<number | null>({
key: 'targetGameId',
default: null,
});
export const targetGameSelector = selector<GameData | undefined>({
key: 'targetGame',
get: ({ get }) => {
return get(gamesAtom).find(
(game: GameData) => game.id === get(selectedGameIdAtom)
);
},
// This is where I'm getting tripped up. Is this the place to do this? What would I write in here?
set: ({ set, get }, newValue) => {},
});
// Elsewhere in the application the data for the targetGame is pulled down and new values are provided for it. For example, perhaps I want to change the difficulty of Twister to "extreme" by sending up the newValue of {...targetGame, difficulty: 'extreme'}
Any help or being pointed in the right direction will be appreciated. Thanks!
I want to create a tree that allows the user to be able to add/remove nodes by clicking on the node that the user would like to add children to/remove the node they click on. I am using the react-expendable-treeview package as I like how it looks visually. Javascript functionality can be found under src/lib/components/
The github repo can be found here: https://github.com/fosco/react-expandable-treeview and the tree looks like so: https://imgur.com/7Y5xcIj
The data that is passed into the TreeView component is predefined in a javascript file like so:
const testData = [
{
id: 0,
name: "Felidae",
children: [
{
id: 1,
name: "Pantherinae",
children: [
{
id: 2,
name: "Neofelis",
},
{
id: 3,
name: "Panthera",
}
]
},
{
I'm wondering how I can set this up so I am able to add/remove children. I am aware that there are many other react tree packages such as react-d3-tree. However, I visually like how this one looks. If anyone knows how I can modify this package or change the theme of react-d3-tree or another package to look like this, please let me know.
Is it possible to have more than one localStorage.getItem in state?
Right now I have this:
const [list, useList] = useState(
JSON.parse(localStorage.getItem("dictionary")) || [] //tasks in my to-do
);
and I should also keep in this state my subtasks, contained in a task, with this structure:
- task {
- id
- body
- subtasks
[{
- id
- body
}]
}
Can I save also the subtasks in local storage and access them with getItem?
These are what I want to use to get my subtasks:
JSON.parse(localStorage.getItem("domain")) || []
JSON.parse(localStorage.getItem("range")) || []
Yes, you can have more than one array of values in local storage. You need to set the item before you can access it though, you should also serialize the object or array to a string when saving it.
localStorage.setItem("dictionary", JSON.stringify([]));
localStorage.setItem("domain", JSON.stringify([]));
localStorage.setItem("range", JSON.stringify([]));
alert(JSON.parse(localStorage.getItem("dictionary")));
alert(JSON.parse(localStorage.getItem("domain")));
alert(JSON.parse(localStorage.getItem("range")));
Lucky me, I saw your other question which contains a running code snippet, you should add it here too!
From what I saw you're trying to create a tree of tasks, dictionary is a task and it can have subtasks such as domain and range, right? Then you should have a data structure like this:
singleTask = {
id: 0,
body: "task",
domain: [
{
id: 00,
body: "subtask domain 1"
},
{
id: 01,
body: "subtask domain 2"
}
],
range: [
{
id: 10,
body: "subtask range 1"
},
{
id: 11,
body: "subtask range 2"
}
]
}
When you're rendering a task as TaskListItem, you render the task.body. Then pass task.domain to a SubtaskDomain component, task.range to a SubtaskRange component.
When you submit a subtask, update the main list in App, after you do that, update local storage, you already do that, but you actually only need one set item, and it's
localStorage.setItem("dictionary", JSON.stringify(listState));
because you have everything in it!