How to share state between reducers in react (using hooks) without redux

Viewed 513

I have a react hooks application written in typescript that contains multiple reducers, and I am using them with the context API.

I need to share the error state between reducers, because I need only one error state displayed in the app, which can be cleared/set with the errorReducer.

The trouble is, I need to set the error part of the state from the 'other' reducers (by 'other' I mean not the errorReducer).

If I try to use the useReducer hook (or my own useAsyncReducer hook) inside the 'other' reducers to set an error, I get

Error: Invalid hook call. Hooks can only be called inside of the body of a function component

, as you can see below.

How can I share state between reducers in react? (please see the 'TODO: THIS IS WHAT I NEED' below).

Please note that I do not want to use redux.

export type Actor = {
    _id?: string,
    firstName?: string,
    lastName?: string,
    selectedForDelete?: boolean
}

// Compound state for 3 reducers (activeEntityTypeReducer, actorReducer, errorReducer)
export type State = {
    activeEntityType: string,
    actors: Actor[],
    error: string | null
}

export const EMPTY_INITIAL_STATE: State = {
    activeEntityType: EntityType.ACTOR,
    actors: [],
    error: null
};


// ERROR REDUCER:
export const errorReducer = async (state: string | null, action: ErrorActions) => {
    switch (action.type) {
        case ErrorActionType.SET_ERROR: {
            return action.payload;
        }

        default:
            return state;
    }
};

// ACTOR REDUCER:
export const actorReducer = async (state: Actor[], action: ActorActions) => {
  // I cannot use here a hook like this because it triggers: "Error: Invalid hook call. Hooks can only be called inside of the body of a function component"
  // const { dispatch: dispatchError } = useAsyncReducer(errorReducer, EMPTY_INITIAL_STATE);
  switch (action.type) {
    //... other actions

    case ActorActionType.SEARCH_ACTORS: {
      return fetch(
        "http://localhost:3000/api/actors?pagesize=100"
      ).then(response => response.json())
        .then(response => response['data']);
      /*  // TODO: THIS IS WHAT I NEED: continue the above line with a catch inside which I dispatch the error
      .catch((error) => dispatchError({
        type: ErrorActionType.SET_ERROR,
        payload: error
      }))
      */
    }

    default:
      return state;
  }
};

// MAIN (COMPOSED) REDUCER:
export const mainReducer = async ({ activeEntityType, actors, error }: State, 
    action: ActiveEntityTypeActions | ActorActions | ErrorActions) => (
    {
        actors: await actorReducer(actors, action as ActorActions),
        activeEntityType: activeEntityTypeReducer(activeEntityType, action as ActiveEntityTypeActions),
        // more reducers here and all need to set the error
        error: await errorReducer(error, action as ErrorActions)
    });
2 Answers

Use context to store error state in Parent component, then use useContext to access state stored in context. Because you need components to update state in context, you include a method for updating state - I don't use Typescript

 const ErrorContext = React.createContext({
  error: null,
  setError: err => {
    this.error = err;
  }
});

let ComponentOne = () => {
  const [link, setLink] = useState("");
  const errorState = useContext(ErrorContext);

  // http fetching is an impure action, it does not belong in reducers
  // in redux we place asnc actions in middlewares such as thunk and saga

  let fetchLink = () => {
    fetch(link)
      .then(res => res.json())
      .then(response => {
        // if data, pass action to reducer to update state
        console.log("++ found something: ", response);
      })
      .catch(exc => {
        console.error("++ exc: ", exc);
        // here we use context to set error state
        errorState.setError("From Component1: " + exc.message);
      });
  };
  return (
    <>
      <input type="text" name="link" onChange={e => setLink(e.target.value)} />
      <button onClick={fetchLink}>fetch content from ComponentTwo</button>
    </>
  );
};

let ComponentTwo = () => {
  const [link, setLink] = useState("");
  const errorState = useContext(ErrorContext);

  // http fetching is an impure action, it does not belong in reducers
  // in redux we place asnc actions in middlewares such as thunk and saga

  let fetchLink = () => {
    fetch(link)
      .then(res => res.json())
      .then(response => {
        // if data, pass action to reducer to update state
        console.log("++ found something: ", response);
      })
      .catch(exc => {
        console.error("++ exc: ", exc);
        // here we use context to set error state
        errorState.setError("From Component2: " + exc.message);
      });
  };
  return (
    <>
      <input type="text" name="link" onChange={e => setLink(e.target.value)} />
      <button onClick={fetchLink}>fetch content from ComponentTwo</button>
    </>
  );
};

export default function App() {
  const [error, setError] = useState("");
  return (
    <ErrorContext.Provider
      value={{
        error,
        setError
      }}
    >
      <div className="App">
        <h1>Error: {error}</h1>
        <hr />
        <ComponentOne />
        <br />
        <ComponentTwo />
      </div>
    </ErrorContext.Provider>
  );
}

Also, API calls do not belong in reducers, best do all async calls inside component and emit actions when you have results or encounter exceptions.

Note: I have a codesandbox

I found a solution:

I changed the actorReducer to update the error part of the state as well. This means that actorReducer receives now both the actors and the error parts of the state and returns both of them.

For simplicity, I renamed the action names to start with words that identify the reducers, so that I can easily decide to which reducer to forward an action in the mainReducer (which is the combined reducer) by just checking the prefix of an incoming action (so ActorActionType.SEARCH_ACTORS was renamed to ActorActionType.ACTOR__SEARCH, etc).

Here is a part of the code:

type State = {
    activeEntityType: string,
    actors: Actor[],
    error: string | null
}

// For actorReducer
enum ActorActionType {
  ACTOR__ADD = 'ACTOR__ADD',
  ACTOR__SELECT_FOR_DELETING = 'ACTOR__SELECT_FOR_DELETING',
  ACTOR__DELETE = 'ACTOR__DELETE',
  ACTOR__SEARCH = 'ACTOR__SEARCH'
}

// For errorReducer
enum ErrorActionType {
    ERROR__SET = 'ERROR__SET',
}

// for activeEntityTypeReducer
const ACTIVE_ENTITY_TYPE__SET: string = "ACTIVE_ENTITY_TYPE__SET";

const actorReducer = async (actors: Actor[], error: string | null, action: ActorActions) => {
  case ActorActionType.ACTOR__SEARCH: {
    // fetch and error handling and fill newActors and newOrOldError
    return { actors: newActors, error: newOrOldError};
  }
}

// The combined (main) reducer:
const mainReducer = async ({ activeEntityType, actors, error }: State, 
    action: ActiveEntityTypeActions | ActorActions | ErrorActions) => {
        if (action.type.startsWith('ACTOR__')) {
            const actorsAndError = (await actorReducer(actors, error, action as ActorActions));
            return {...actorsAndError, activeEntityType};
        }

        if (action.type.startsWith('ACTIVE_ENTITY_TYPE__')) {
            // No need for now to pass in and return the error for activeEntityTypeReducer, as it can never error
            return {activeEntityType: activeEntityTypeReducer(activeEntityType, action as ActiveEntityTypeActions), actors, error};
        }

        // Can only be the errorReducer case:
        return {activeEntityType, actors, error: await errorReducer(error, action as ErrorActions)};
};
Related