Search code examples
javascriptreactjsreduxreact-reduxredux-toolkit

How to control state of dynamic components with React and Redux?


How can I control the state of components that get added dynamically using Redux? I'm able to do it when controlling state with React, but when I introduced Redux, every component has the same state.

In the example below, click the button to add a parent. Then click the button to add a new child. When clicking the add new parent button again, the second parent has the same number of children as the first. Is it possible to manage each parent state separately, knowing that there can be any number of parents, or should I stick with controlling the state with React?

The next step would be to then print the text in all of the children with the print child info button for each parent, but since the state of the children is controlled in a similar manner to the parents, I think that'll be easy once I get over the first hurdle.

Here's the directory structure and code:

├── src
│   ├── App.jsx
│   ├── appSlice.js
│   ├── components
│   │   ├── Child.jsx
│   │   ├── Parent.jsx
│   │   ├── childSlice.js
│   │   └── parentSlice.js
│   ├── main.jsx
│   └── store
│       └── store.js

main.jsx

import React from 'react'
import ReactDOM from 'react-dom/client'
import { Provider } from "react-redux";
import App from './App'
import { store } from "./store/store";

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <Provider store={store}>
      <App />
    </Provider>
  </React.StrictMode>,
)

App.jsx

import { useDispatch, useSelector } from "react-redux"
import { addParent } from "./appSlice"
import Parent from "./components/Parent";

export default function App() {
  const dispatch = useDispatch();
  const {parents} = useSelector(state => state.app)

  return (
    <div style={{
      display: 'flex', 
      justifyContent: 'center', 
      flexDirection: 'column',
      margin: '2rem'
      }}>
      <button onClick={() => dispatch(addParent())}>Add parent</button>
      <br />
      {parents.map((item) => (
        <Parent key={item}/>
      ))}
    </div>
  )
}

appSlice.js

import { v4 as uuid} from "uuid"
import { createSlice } from "@reduxjs/toolkit";

const appSlice = createSlice({
  name: "app",
  initialState: {
    parents: []
  },
  reducers: {
    addParent: (state) => {
      state.parents = [...state.parents, uuid()];
    },
  },
});

export const { addParent } = appSlice.actions;

export default appSlice.reducer;

store/store.js

import { configureStore } from "@reduxjs/toolkit";
import appReducer from "../appSlice";
import parentReducer from "../components/parentSlice";
import childReducer from "../components/childSlice"

export const store = configureStore({
  reducer: {
    app: appReducer,
    parent: parentReducer,
    child: childReducer
  },
});

components/Parent.jsx

import React from 'react'
import Child from './Child'
import { useDispatch, useSelector } from "react-redux"
import { addChild } from "./parentSlice"

export default function Parent() {
  const dispatch = useDispatch()
  const {children} = useSelector(state => state.parent)

  return (
    <div style={{
      width: "100%",
      height: "100px",
      border: "1px solid black",
      marginBottom: "10px",
    }}>
      <button onClick={() => dispatch(addChild())}>Add new child</button>
      {children.map(item => (
        <Child key={item} />
      ))}
      <button>Print child info</button>
    </div>
  )
}

components/parentSlice.js

import { v4 as uuid} from "uuid"
import { createSlice } from "@reduxjs/toolkit";

const parentSlice = createSlice({
  name: "parent",
  initialState: {
    children: []
  },
  reducers: {
    addChild: (state) => {
      state.children = [...state.children, uuid()];
    },
  },
});

export const { addChild } = parentSlice.actions;

export default parentSlice.reducer;

components/Child.jsx

import React from 'react'
import { useDispatch } from 'react-redux'
import { updateText } from './childSlice';

export default function Child() {
  const dispatch = useDispatch();
  return (
    <div>
      <input 
        type='text' 
        onChange={(event) => dispatch(updateText(event.target.value))}
      ></input>
    </div>
  )
}

components/childSlice.js

import { createSlice } from "@reduxjs/toolkit";

const childSlice = createSlice({
  name: "child",
  initialState: {
    text: "",
  },
  reducers: {
    updateText: (state, {payload}) => {
      state.text = payload;
    },
  },
});

export const { updateText } = childSlice.actions;

export default childSlice.reducer;

Solution

  • In React, the UI is a function of state and props. In this case, the state is your Redux store. Basically what's missing from your code is the association of child id values to some parent id value. All the children are using the same single state.child state value, nothing to differentiate them at all.

    I suggest the following state slice changes:

    Update the State

    appSlice - Nothing to change here, this remains the array of parent "node" ids.

    const appSlice = createSlice({
      name: "app",
      initialState: {
        parents: []
      },
      reducers: {
        addParent: (state) => {
          state.parents = [...state.parents, uuid()];
        }
      }
    });
    

    parentSlice - Here you want the state to be an object where the generated parent ids are the keys and the values are arrays of children id values.

    const parentSlice = createSlice({
      name: "parent",
      initialState: {
        children: {}
      },
      reducers: {
        addChild: (state, action) => {
          if (!state.children[action.payload]) {
            state.children[action.payload] = [];
          }
          state.children[action.payload].push(uuid());
        }
      }
    });
    

    childSlice - Similarly the child state will be an object where the keys are the child id values and the values are the input value.

    const childSlice = createSlice({
      name: "child",
      initialState: {},
      reducers: {
        updateText: (state, action) => {
          state[action.payload.id] = action.payload.value;
        }
      }
    });
    

    Rendering the UI from State

    App - Select state.app.parents (or Object.keys(state.parent)!) and map this array to the Parent component and pass the id as a prop.

    export default function App() {
      const dispatch = useDispatch();
      const { parents } = useSelector((state) => state.app);
    
      return (
        <div
          style={{
            display: "flex",
            justifyContent: "center",
            flexDirection: "column",
            margin: "2rem"
          }}
        >
          <button onClick={() => dispatch(addParent())}>Add parent</button>
          <br />
          {parents.map((id) => (
            <Parent key={id} id={id} />
          ))}
        </div>
      );
    }
    

    Parent - Similarly, take the passed id prop and select state.parent.children[id] to get this parent's array of children id values. Map these to Children component and again pass the child's id value as a prop. Parent will pass its id value to the dispatched addChild action so the reducer knows which parent to add a child to.

    export default function Parent({ id }) {
      const dispatch = useDispatch();
      const children = useSelector((state) => state.parent.children[id]);
    
      return (
        <div
          style={{
            width: "100%",
            height: "100px",
            border: "1px solid black",
            marginBottom: "10px"
          }}
        >
          <button onClick={() => dispatch(addChild(id))}>Add new child</button>
          {children?.map((childId) => (
            <Child key={childId} id={childId} />
          ))}
          <button>Print child info</button>
        </div>
      );
    }
    

    Child - The child component will select its value via state.child[id] using the passed id prop and render its input. Child will pass its id value and the input value to the dispatched updateText action so the reducer function knows which child id is being updated.

    export default function Child({ id }) {
      const dispatch = useDispatch();
      const value = useSelector((state) => state.child[id]);
    
      return (
        <div>
          <input
            type="text"
            onChange={(event) =>
              dispatch(updateText({ id, value: event.target.value }))
            }
            value={value || ""}
          />
        </div>
      );
    }
    

    Demo

    Edit how-to-control-state-of-dynamic-components-with-react-and-redux