2

I have some deeply nested react components. Let's limit them to 3 levels of nesting and call them Parent, Children and Grandchildren. The state for the entire app is stored in a single object in the Parent component, and is managed using the useState hook.

export interface Parent {
    children: Child[];
}

export interface Child {
    weight: number;
    grandchildren: Grandchild[];
}

export interface Grandchild {
    age: number;
}

I have two alternative ways to update the state: by incrementally updating the state at each level and bubbling up to the root, or incrementally building an array containing the path to the target node and updating the state just once at the root level.

Method 1: Incremental Updates

export interface ContainerProps<T> {
    index: number;
    value: T;
    updateHandler: any;
}

const ParentContainer: React.FC = () => {
    const [value, setValue] = useState<Parent>(); // source of truth

    const updateChild = (childIndex: number, child: Child) => {
        value.children[childIndex] = child;
        setValue({...value});
    };

    return (
        <div className="Parent">
            {value.children.map((child, index) => (
                <ChildContainer key={index} index={index} value={child} updateHandler={updateChild} />
            ))}
        </div>
    );
}

const ChildContainer: React.FC<ContainerProps<Child>> = ({index, value, updateHandler}) => {
    const updateGrandchild = (grandchildIndex: number, grandchild: Grandchild) => {
        value.grandchildren[grandchildIndex] = grandchild;
        updateHandler(value);
    };

    const increaseWeight = () => {
        updateHandler(index, {
            weight: value.weight + 1
        })
    }

    return (
        <div className="Child">
            <button onClick={event => increaseWeight()}>
                Increase weight
            </button>

            {value.grandchildren.map((grandchild, index) => (
                <ChildContainer key={index} index={index} value={grandchild} updateHandler={updateGrandchild} />
            ))}
        </div>
    );
}

const GrandchildContainer: React.FC<ContainerProps<Grandchild>> = ({index, value, updateHandler}) => {
    const increaseAge = () => {
        updateHandler(index, {
            age: value.age + 1
        })
    }

    return (
        <div className="Grandchild">
            <button onClick={event => increaseAge()}>
                Increase age
            </button>
        </div>
    );
}

Method 2: Path array

export type PathElement = string | number;

const ParentContainer: React.FC = () => {
    const [value, setValue] = useState<Parent>(); // source of truth

    // e.g. to set a grandchild's age to 2,
    // path = [0, 'grandchildren', 2, 'age'], item = 2
    const update = (path: PathElement[], item: any) => {
        let node = value['children'];
        for (let i = 0; i < path.length; i++) {
            if (i === path.length - 1) {
                node[path[i]] = item;
            }
            else {
                node = node[path[i]];
            }
        }

        setValue({...value});
    };

    return (
        <div className="Parent">
            {value.children.map((child, index) => (
                <ChildContainer key={index} index={index} value={child} updateHandler={updateChild} />
            ))}
        </div>
    );
}

const ChildContainer: React.FC<ContainerProps<Child>> = ({index, value, updateHandler}) => {
    const updateGrandchild = (path: PathElement, item: any) => {
        updateHandler([index, 'grandchildren'].concat(path), item);
    };

    const increaseWeight = () => {
        updateHandler([index, 'weight'], value.weight + 1);
    }

    return (
        <div className="Child">
            <button onClick={event => increaseWeight()}>
                Increase weight
            </button>

            {value.grandchildren.map((grandchild, index) => (
                <ChildContainer key={index} index={index} value={grandchild} updateHandler={updateGrandchild} />
            ))}
        </div>
    );
}

const GrandchildContainer: React.FC<ContainerProps<Grandchild>> = ({index, value, updateHandler}) => {
    const increaseAge = () => {
        updateHandler([index, 'age'], value.age + 1);
    }

    return (
        <div className="Grandchild">
            <button onClick={event => increaseAge()}>
                Increase age
            </button>
        </div>
    );
}

My thoughts:

  • Method 1:
    • ✔️ looks simpler
    • ✔️ has type checking at every level, ensuring error messages are thrown from the guilty component
    • ❌ requires many object assignments, each gradually increasing in size, hence reducing time and memory performance
  • Method 2
    • ✔️ the number of assignments is linear according to the depth of the target node, and hence has better time and memory performance
    • ❌ code is more complex
    • ❌ has poorer type checking, meaning errors will not be discovered until they reach the root component

I understand that for frontend code maintainability takes priority over minor performance concerns. However, there is one use case for which method 2 is more suitable: suppose the parent component has a variable totalAge, which is updated whenever a grandchild's age is updated. With method 1, it is impossible to know which element was updated without either passing down an extra age handler, or adding extra arguments to the existing update handler. With method 2, one can simply check if the last element of the path is 'age'.

What are your thoughts? Feedback on any aspect of the code is greatly appreciated!

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.