useReducer state is apparently not reactive

I have had an idea to build a React store that is somewhat like Vuex. I want a wrapper component to receive an object of values and provide, using useContext, an object back that attaches a state and set to each key in the object.

For example, if you wrapped your app in something like <StoreProvider value={{ color: 'red' }}> you could in any child write const { color } = useContext(StoreContext), use its value with color.state, and then call color.set('blue') to globally change that value.

I have written a best-attempt at the store with useReducer:

import React, {
    createContext,
    useContext,
    useReducer
} from 'react'

const StoreContext = createContext(null)
export const useStoreContext = () => useContext(StoreContext)

export const StoreProvider = ({ children, value }) => {
    
    const reducer = (state, action) => {
        state[action.type].state = action.payload
        return state
    }

    Object.keys(value).map(key => {
        let state = value[key]
        value[key] = {
            state: state,
            set: (setter) => dispatch({ type: key, payload: setter })
        }
    })
 
    const [store, dispatch] = useReducer(reducer, value)

    return (
        <StoreContext.Provider value={store}>
            {children}
        </StoreContext.Provider>
    )
}

And a little demo to see if it works:

import React from 'react'
import ReactDOM from 'react-dom'

import { StoreProvider, useStoreContext } from './store'

const App = () => {
  const { color } = useStoreContext()

  return (
    <>
    <h1>{color.state}</h1>
    <button onClick={() => color.set('yellow')}>Change color</button>
    <button onClick={() => console.log(color)}>Log</button>
    </>
  )
}

ReactDOM.render(
  <StoreProvider value={{
    color: 'orange'
  }}>
    <App />
  </StoreProvider>,
  document.getElementById('root')
)

In the above, the <h1> renders as 'orange' correctly, but doesn't update to 'yellow' when the set('yellow') is run. If I log color, however, the state has updated to 'yellow' -- which I guess means the state isn't reactive.

Have I done something really stupid? I'm pretty nooby with React and have never used useReducer before.


Solution 1:

I feel you missing a very important point in React that you should not ever mutate the state object. state[action.type].state = action.payload is a state mutation. On top of the mutation, you simply return the same state object. In order for React state updates to work correctly you necessarily need to return new object references.

const reducer = (state, action) => {
  return {
    ...state,                // <-- shallow copy state
    [action.type]: {
      ...state[action.type], // <-- shallow copy nested state
      state: action.payload,
    }
  };
}

You are also misusing the array.map callback; you should really use a forEach is you are iterating an array and issuing side-effects. array.map is considered a pure function.

Object.keys(value).forEach(key => {
  let state = value[key]
  value[key] = {
    state: state,
    set: (setter) => dispatch({ type: key, payload: setter })
  }
});

I couldn't get your initial value mapping to work correctly, seems it was doing some extra nesting of properties that didn't quite match how you were accessing in the UI nor updating. I suggest the following for computing your initial reducer state value. Iterate the array of key-value pairs of the value object passed to the context, reducing into an object with the same keys and the value and setter nested correctly.

const StoreProvider = ({ children, value }) => {
  const reducer = (state, action) => {
    return {
      ...state,
      [action.type]: {
        ...state[action.type],
        state: action.payload
      }
    };
  };

  const state = Object.entries(value).reduce(
    (state, [key, value]) => ({
      ...state,
      [key]: {
        state: value,
        set: (setter) => dispatch({ type: key, payload: setter })
      }
    }),
    {}
  );

  const [store, dispatch] = useReducer(reducer, state);

  return (
    <StoreContext.Provider value={store}>{children}</StoreContext.Provider>
  );
};

Edit usereducer-state-is-apparently-not-reactive