How to update the state of nested json object in react using hooks

I have a global state of the format like this:

{
  type: "container",
  items: [
    {
      type: "box"
    },
    {
      type: "container",
      items: [
        type: "container",
        items: [
          {
            type: "box",
            color: "green"
          },
          {
            type: "box",
            color: "red"
          },
          {
            type: "box"
          }
        ]
      ]
    }
  ]
}

The state above will produce the output below: output

As you can see, we have only two elements: containers and boxes. Containers have buttons and can contain either containers or boxes. Boxes can only change their colors. To understand better watch this demo.

I have been able to replicate this behavior, but I want to be able to extract the whole state of the app and be able to restore it.

There is no problem with restoring states. If I pass a json like above, it will work. But when it comes to saving my current state, problems arise. You see, when I try to restore my state, I pass nested object as a prop and then it becomes the local state of that child container. But when I change the state of that child container, it obviously doesn’t change the global state (the json above).

So I tried 2 methods to change the global state:

Here is my code:

  1. Create onChange method that will trigger when some local state changes. Then pass that method as a prop, and call that method by passing the changed state as a parameter. (You can see this in my code)
  2. Pass path (list of indices) of the nested object as a prop to restore to the form state.items[path[0]].items[path[1]].items[path[n]] and then change it manually by setState hook callback.

In both cases, I get

Warning: Maximum update depth exceeded. This can happen when a component calls setState inside useEffect, but useEffect either doesn't have a dependency array, or one of the dependencies changes on every render.

and it just freezes.

Container.js

import React, { useState } from 'react'
import { v4 as uuidv4 } from 'uuid'
import Box from './Box'

function Container({ current, path, onChange }) {
  const styles = ... some styles ...
  
  const [popoverVisible, setPopoverVisibility] = useState(false)

  const [state, setState] = useState(current || [])

  React.useEffect(() => onChange(state, path), [state])

  const handleButtonMouseOver = () => setPopoverVisibility(true)
  const handleButtonMouseLeave = () => setPopoverVisibility(false)
  const handlePopoverMouseOver = () => setPopoverVisibility(true)
  const handlePopoverMouseLeave = () => setPopoverVisibility(false)

  const props = {
    container: { style: styles.container },
    wrapper: { style: styles.wrapper },
    popover: {
      style: styles.popover,
      onMouseOver: handlePopoverMouseOver,
      onMouseLeave: handlePopoverMouseLeave
    },
    buttonAdd: {
      style: styles.button,
      onMouseOver: handleButtonMouseOver,
      onMouseLeave: handleButtonMouseLeave
    },
    buttonBox: {
      style: styles.button,
      onClick: () => setState([...state, {type: 'box'}])
    },
    buttonContainer: {
      style: styles.button,
      onClick: () => setState([...state, {type: 'container', items: []}])
      //onClick: () => setState({...state, items: [...state.items, {type: 'container', items: []}]})
    }
  }

  return (
    <>
      <div {...props.container}>
        {state.map((x, i) => x.type === 'container' ? <Container key={uuidv4()} current={x.items} onChange={ onChange } path={[...path, i]}></Container> : <Box key={uuidv4()}></Box>)}
        <div {...props.wrapper}>
          <div>
            <div {...props.popover}>
              {popoverVisible && <button {...props.buttonBox}>Box</button>}
              {popoverVisible && <button {...props.buttonContainer}>Container</button>}
            </div>
            <button {...props.buttonAdd}>Add</button>
          </div>
        </div>
      </div>
    </>
  )
}

export default Container

App.js

import { useState, useEffect } from 'react'
import Container from './Container.js'

function App() {
  const styles = {
    button: {
      margin: ".5em",
      width: "7em",
      height: "3em",
      border: "1px solid black",
      borderRadius: ".25em"
    }
  }

  const [state, setState] = useState({type: "container", items: []})
  const [newState, setNewState] = useState("")
  const [generatedJson, setGeneratedJson] = useState("")

  const handleContainerChange = (s, p) => setState({...state, items: s})
  const handleTextareaChange = e => setNewState(e.target.value)
  const handleBuildButtonClick = () => setState(JSON.parse(newState))
  const handleCreateJsonButtonClick = () => setGeneratedJson(JSON.stringify(state))

  //useEffect(() => {alert(state)}, [state])

  return (
    <>
      <div style={{ display: "flex" }}>
        <Container key={state} current={state.items} path={[]} onChange={ handleContainerChange }>
        </Container>
      </div>
      <textarea cols="30" rows="5" onChange={ handleTextareaChange }></textarea><br/>
      <button style={styles.button} onClick={ handleBuildButtonClick }>Build</button><br/>
      <button style={styles.button} onClick={ handleCreateJsonButtonClick }>Create JSON</button>
      <label key={generatedJson}>{generatedJson}</label>
    </>
  )
}

export default App

So what I want is to update global state, when local state changes. Example:

globalState = {
  type: "container",
  items: [
    { type: "container", items: [] }
  ]
}

// that inner container from the global state
localState = { type: "container", items: [] }

// so when it changes to

localState = { 
  type: "container", 
  items: [
    {type: "box"}
  ]
}

// i want the global state to become

globalState = {
  type: "container",
  items: [
    { 
      type: "container", 
      items: [
        {type: "box"}
      ]
    }
  ]
}

How can I do that?

Answer

Issue

I think the main issue is the usage of state as a React key on the Container component in App. Each time state updates you are specifying a new React key, and React will handle this by unmounting the previous version and mounting a new version of Container.

<Container
  key={state} // <-- new key each state update
  current={state.items}
  path={[]}
  onChange={ handleContainerChange }
>
</Container>

Each time Container mounts it will instantiate state and run the useEffect, which calls the passed onChange prop.

const [state, setState] = useState(current || []);

React.useEffect(() => onChange(state, path), [state]);

Solution

Remove the React key on Container in App.

<Container
  current={state.items}
  path={[]}
  onChange={ handleContainerChange }
/>