DEV Community

Cover image for Gestire una struttura ad albero in react con useReducer
Paolo Carraro
Paolo Carraro

Posted on

Gestire una struttura ad albero in react con useReducer

Recentemente mi sono trovato a dover editare una struttura che presentava dai 3 ai 4 livelli di annidamento: entità che contenevano liste di altre entità anche se non esplicitamente in ricorsione. La migliore soluzione che ho trovato finora è quella di utilizzare:

  • un'interfaccia che rappresenta il nodo dell'albero
  • useReducer come state management
  • una funzione ricorsiva per mutare l'intero albero

Treeitem: il tipo per rappresentare il nodo

Questa l'interfaccia che rappresenta il nodo

export type TreeNode = {
    id: string,
    name: string,
    items?: TreeNode[],
}

Enter fullscreen mode Exit fullscreen mode

In questo caso per semplificare ho già previsto la prop name che sarà l'unico dato che andremo ad editare ma in generale TreeItem è da usare come estensione per le entità che saranno sicuramente più complesse.

State management con useReducer

L'hook useReducer può essere il punto di partenza per poter gestire le azioni/eventi e i cambi di stato conseguenti, ma si potrebbero valutare state management più evoluti.
Vediamo la definizione delle azioni base:

type TreeAction =
    | { type: "add-item", payload: { parent: TreeNode, index: number } }
    | { type: "remove-item", payload: { parent: TreeNode, id: string } }
    | { type: "edit-set-item", payload: { nodeId: string } }
    | { type: "edit-apply", payload: { parent: TreeNode, node: TreeNode, } }
    | { type: "edit-cancel" }
Enter fullscreen mode Exit fullscreen mode

L'azione di editing si compone di almeno 2 parti: l'attivazione alla modifica del nodo e le conseguenti azioni di conferma o annullamento.
Per quanto riguarda il modello dello stato come punto di partenza basterà

type TreeState = {
    root: TreeNode,
    idInEdit?: string,
}
Enter fullscreen mode Exit fullscreen mode

Lo stato quindi rappresenterà l'intero albero che verrà mutato in conseguenza delle azioni di aggiunta, rimozione o modifca dei nodi e l'eventuale id del nodo in modifica.

Funzione di mutazione dell'albero

La funzione che si occuperà di restiture il nuovo albero utilizzerà la ricorsione per la ricerca e sostituzione dei figli del nodo che stiamo modificando.
Anche nel caso di edit di un singolo nodo andremo comunque a modificare i figli del nodo padre di quest'ultimo.

function mutateTree(root: TreeNode, nodeIdToUpdate: string, newItems: TreeNode[]): TreeNode {
    function updateNodes(node: TreeNode,) {
        if (node.id === nodeIdToUpdate) {
            return { ...node, id: node.id, items: newItems }
        }

        if (node.items) {
            node.items = node.items.map(updateNodes);
        }
        return node;
    }
    return updateNodes(root);
}
Enter fullscreen mode Exit fullscreen mode

Aggiunta di un nuovo nodo

Questa azione si prefigge di gestire due casi

  • aggiunta di un nodo figlio a partire dal nodo padre
  • aggiunta di un nodo fratello a partire dal nodo stesso

L'indice, in questo esempio, viene passato solo in caso di aggiunta di un nodo fratello

case "add-item": {
    const { index, parent } = action.payload;
    let { items } = parent;
    if (!items) items = [];
    let newItems = [];
    if (index === -1) {
        newItems = [
            ...items,
            { id: generateId(), name: "my name is?" },
        ];
    } else {
        newItems = [
            ...items.slice(0, index + 1),
            { id: generateId(), name: "my name is?" },
            ...items.slice(index + 1),
        ]
    }
    const root = mutateTree(state.root, parent.id, newItems);
    return { ...state, root, idInEdit: undefined }
}
Enter fullscreen mode Exit fullscreen mode

Rimozione di un nodo

Questa azione viene sempre invocata dal nodo stesso che vogliamo rimuovere

case "remove-item": {
    const { id, parent } = action.payload;
    const { items } = parent;
    if (!items) return state;
    let newItems = items.filter(item => item.id !== id);
    const root = mutateTree(state.root, parent.id, newItems);
    return { ...state, root, idInEdit: undefined }
}
Enter fullscreen mode Exit fullscreen mode

Le azioni di modifica nodo

Le azioni di set e cancel sono molto semplici in quanto vanno ad agire sulla prop dello stato che tiene traccia dell'id che stiamo modificando.
L'azione di conferma modifica invece prende in carico i dati raccolti dal form di modifica e li va ad applicare al nodo con id precedentemente tracciato.

case "edit-set-item": {
    const { nodeId } = action.payload;
    return { ...state, idInEdit: nodeId }
}

case "edit-apply": {
    const { node, parent } = action.payload;
    const { items } = parent;
        if (!items) return state;
        const newItems = items.map(item => {
            if (item.id === node.id) {
                return node;
            }
            return item;
        });
        const root = mutateTree(state.root, parent.id, newItems);
        return { ...state, root, idInEdit: undefined }
    }

    case "edit-cancel": {
        return { ...state, idInEdit: undefined }
    }
Enter fullscreen mode Exit fullscreen mode

Componenti di renderizzazione nodi albero

Il componente di renderizzazione del signolo nodo sarà rappresentato da due diversi componenti a seconda se questo è in modalità modifica o meno, ma in entrambi i casi avranno come prop

type TreeNodeComponentProps = {
    parent?: TreeNode;
    node: TreeNode;
    index: number;
    dispatch: Dispatch<TreeAction>;
    idInEdit?: string;
}
Enter fullscreen mode Exit fullscreen mode

Da notare che il parent potrebbe essere non definito per il caso del nodo root, e il prop-drilling di dispatch, per lanciare le azioni a qualsiasi livello di annidamento, e id nodo per la modifica dello stato del nodo stesso.

const TreeNodeComponent = (props: TreeNodeComponentProps) => {
    const { node, dispatch, idInEdit } = props;
    const { items } = node;
    return (<li>
        {!(idInEdit === node.id) ? <TreeNodeViewer {...props} /> : null}
        {idInEdit === node.id ? <TreeNodeEditor {...props} /> : null}
        <ol>
            {items ? items.map((item, index) => <TreeNodeComponent key={item.id} parent={node} node={item} index={index} idInEdit={idInEdit} dispatch={dispatch} />) : null}
        </ol>
    </li>)
}

Enter fullscreen mode Exit fullscreen mode

Per la soluzione completa trovate il codice in questo repository.
E voi quali soluzioni avete addottato per gestire un caso simile?

Top comments (0)