javascriptnoob
javascriptnoob

Reputation: 121

Updating Recursive Object state in Redux

I want to update the 'name' stored in child object of redux state.

Currently, I am using redux toolkit and storing, 'TElement' data (from api) in redux state. TElement has recursice data structure. I was able to map out all the child components in React. However, I don't know how to go about updating the state of TElement's elements.

createSlice.ts

export interface TElement  {
  id: string;
  name: string;
  link: string;
  elements: TElement[];
};

const initalState: TElements = {
  TElement: {
  id: '',
  name: '',
  link: '',
  elements: []
 }
}
const systemSlice = createSlice({
name: 'system',
initialState: initialState as TElements,
reducers:{}
})
export const root = (state: RootState): TElements['TElement'] =>
  state.system.TElement;

Component.tsx 'Wish to update name in input field'

const File: React.FC<TElement> = ({
  id,
  name,
  link,
  elements,
}: TElement) => {
    const [showChildren, setShowChildren] = useState<boolean>(false);
  const handleClick = useCallback(() => {
    setShowChildren(!showChildren);
  }, [showChildren, setShowChildren]);

return (
<div>
        <input
          onClick={handleClick}
          style={{ fontWeight: showChildren ? 'bold' : 'normal' }}>
          {name}
        </input>
      <div
        style={{
          position: 'relative',
          display: 'flex',
          flexDirection: 'column',
          left: 25,
          borderLeft: '1px solid',
          paddingLeft: 15,
        }}>
        {showChildren &&
          (child ?? []).map((node: FileNode) => <File key={id} {...node} />)}
      </div>
</div>
)

function TaskFilter(): JSX.Element {
  const root = useSelector(root);

  return (
    <div>
      <File {...root} />
    </div>
  );
}

export default TaskFilter;

Upvotes: 1

Views: 1338

Answers (2)

HMR
HMR

Reputation: 39290

To understand recursion you have to understand recursion. Here is an example that will recursively render and in the action provide all the parent ids to the update so the reducer can recursively update.

const { Provider, useDispatch, useSelector } = ReactRedux;
const { createStore, applyMiddleware, compose } = Redux;

const initialState = {
  elements: [
    {
      id: '1',
      name: 'one',
      elements: [
        {
          id: '2',
          name: 'two',
          elements: [
            {
              id: '3',
              name: 'three',
              elements: [],
            },
          ],
        },
      ],
    },
    {
      id: '4',
      name: 'four',
      elements: [],
    },
  ],
};
//action types
const NAME_CHANGED = 'NAME_CHANGED';
//action creators
const nameChanged = (parentIds, id, newName) => ({
  type: NAME_CHANGED,
  payload: { parentIds, id, newName },
});
//recursive update for reducer
const recursiveUpdate = (
  elements,
  parentIds,
  id,
  newName
) => {
  const recur = (elements, parentIds, id, newName) => {
    //if no more parent ids
    if (parentIds.length === 0) {
      return elements.map((element) =>
        element.id === id
          ? { ...element, name: newName }
          : element
      );
    }
    const currentParent = parentIds[0];
    //recursively update minus current parent id
    return elements.map((element) =>
      element.id === currentParent
        ? {
            ...element,
            elements: recursiveUpdate(
              element.elements,
              parentIds.slice(1),
              id,
              newName
            ),
          }
        : element
    );
  };
  return recur(elements, parentIds, id, newName);
};
const reducer = (state, { type, payload }) => {
  if (type === NAME_CHANGED) {
    const { parentIds, id, newName } = payload;
    return {
      ...state,
      elements: recursiveUpdate(
        state.elements,
        parentIds,
        id,
        newName
      ),
    };
  }
  return state;
};
//selectors
const selectElements = (state) => state.elements;
//creating store with redux dev tools
const composeEnhancers =
  window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore(
  reducer,
  initialState,
  composeEnhancers(
    applyMiddleware(() => (next) => (action) =>
      next(action)
    )
  )
);
//Element will recursively call itself
const Element = React.memo(function ElementComponent({
  parentIds,
  element,
}) {
  const dispatch = useDispatch();
  const onNameChange = (e) =>
    dispatch(
      nameChanged(parentIds, element.id, e.target.value)
    );
  const { id } = element;
  console.log('render', id);
  //make parentIds array for children, use memo to not needlessly
  //  re render all elements on name change
  const childParentIds = React.useMemo(
    () => parentIds.concat(id),
    [parentIds, id]
  );
  return (
    <li>
      <input
        type="text"
        value={element.name}
        onChange={onNameChange}
      />
      {/* SO does not support optional chaining but you can use
      Boolean(element.elements?.length) instead */}
      {Boolean(
        element.elements && element.elements.length
      ) && (
        <ul>
          {element.elements.map((child) => (
            // recursively render child elements
            <Element
              key={child.id}
              element={child}
              parentIds={childParentIds}
            />
          ))}
        </ul>
      )}
    </li>
  );
});
const App = () => {
  const elements = useSelector(selectElements);
  const parentIds = React.useMemo(() => [], []);
  return (
    <ul>
      {elements.map((element) => (
        <Element
          key={element.id}
          parentIds={parentIds}
          element={element}
        />
      ))}
    </ul>
  );
};

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.4/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.4/umd/react-dom.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/redux/4.0.5/redux.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-redux/7.2.0/react-redux.min.js"></script>
<div id="root"></div>

Upvotes: 1

Linda Paiste
Linda Paiste

Reputation: 42228

My recommendation would be to store them in a flat structure. This makes it more difficult to store them (if they are coming from the API in a nested structure), but much easier to update them.

You would store a dictionary of elements keyed by their id so that you can look up and update an element easily. You would replace the recursive element property with an array of the childIds of the direct children.

export interface TElement {
  id: string;
  name: string;
  link: string;
  elements: TElement[];
}

export type StoredElement = Omit<TElement, "elements"> & {
  childIds: string[];
};

Here's what your slice might look like:

export const elementAdapter = createEntityAdapter<StoredElement>();

const flatten = (
  element: TElement,
  dictionary: Record<string, StoredElement> = {}
): Record<string, StoredElement> => {
  const { elements, ...rest } = element;
  dictionary[element.id] = { ...rest, childIds: elements.map((e) => e.id) };
  elements.forEach((e) => flatten(e, dictionary));
  return dictionary;
};

const systemSlice = createSlice({
  name: "system",
  initialState: elementAdapter.getInitialState({
    rootId: "" // id of the root element
  }),
  reducers: {
    receiveOne: (state, { payload }: PayloadAction<TElement>) => {
      elementAdapter.upsertMany(state, flatten(payload));
    },
    receiveMany: (state, { payload }: PayloadAction<TElement[]>) => {
      payload.forEach((element) =>
        elementAdapter.upsertMany(state, flatten(element))
      );
    },
    rename: (
      state,
      { payload }: PayloadAction<Pick<TElement, "id" | "name">>
    ) => {
      const { id, name } = payload;
      elementAdapter.updateOne(state, { id, changes: { name } });
    }
  }
});

export const { receiveOne, receiveMany, rename } = systemSlice.actions;

export default systemSlice.reducer;

And the store:

const store = configureStore({
  reducer: {
    system: systemSlice.reducer
  }
});

type RootState = ReturnType<typeof store.getState>;
type AppDispatch = typeof store.dispatch;

export const useSelector = createSelectorHook<RootState>();
const { selectById } = elementAdapter.getSelectors(
  (state: RootState) => state.system
);

And your components:

const RenderFile: React.FC<StoredElement> = ({ id, name, link, childIds }) => {
  const dispatch = useDispatch();

  const [showChildren, setShowChildren] = useState(false);

  const handleClick = useCallback(() => {
    setShowChildren((prev) => !prev);
  }, [setShowChildren]);

  const [text, setText] = useState(name);

  const onSubmitName = () => {
    dispatch(rename({ id, name: text }));
  };

  return (
    <div>
      <div>
        <label>
          Name:
          <input
            type="text"
            value={text}
            onChange={(e) => setText(e.target.value)}
          />
        </label>
        <button onClick={onSubmitName}>Submit</button>
      </div>
      <div>
        <div onClick={handleClick}>
          Click to {showChildren ? "Hide" : "Show"} Children
        </div>
        {showChildren && childIds.map((id) => <FileById key={id} id={id} />)}
      </div>
    </div>
  );
};

const FileById: React.FC<{ id: string }> = ({ id }) => {
  const file = useSelector((state) => selectById(state, id));

  if (!file) {
    return null;
  }

  return <RenderFile {...file} />;
};

const TaskFilter = () => {
  const rootId = useSelector((state) => state.system.rootId);

  return (
    <div>
      <FileById id={rootId} />
    </div>
  );
};

export default TaskFilter;

Code Sandbox Link

Upvotes: 3

Related Questions