DEV Community

Cover image for Why & How to implement a redux-like shared state in React
Mazhar Zandsalimi
Mazhar Zandsalimi

Posted on

Why & How to implement a redux-like shared state in React

Why?

  • You have a complex app that has many components which all use a shared state
  • You have a rather large data which is computation-heavy to transform in any way, so you need to make sure you keep your mutations as few as possible (compared to when using hooks which basically call the hook in every render and in every component that uses the hook)
  • Also if you have inner dependencies between parts of your state, it gets more and more complicated when you use hooks, but this way, you have a single repository of state which makes it a bit easier to maintain
  • Your context is getting bigger and harder to handle

Why not?

  • You might be able to get away with hooks depending on your situation
  • You don't have too many nested components and you can pass the data easily as props (or pass down functions for reverse data access)
  • You think that this solution is overkill for your use-case

Disclaimer

I'm using a very simple app here so that there are less boilerplate for you to see the solution clearly. This is absolutely mental to implement for such simple use-cases, just thought I should mention. I personally used this on a project that had about 13 levels of nested components, that used and mutated on the same state. Also we have 2 reducers here just so we have a plural number of reducers :D In the real project, I had 11 different reducers which had their own duties.

file structure

├── components
    ├── visit.tsx
    └── clicks.tsx
├── counter.context.ts # defines the context
├── counter.init.ts # initial state
├── counter.provider.tsx # state wrapper
├── counter.types.ts
└── reducers
    ├── index.ts # root reducer
    ├── visit.reducer.ts
    └── clicks.reducer.ts
Enter fullscreen mode Exit fullscreen mode

Overview

Let' make an app which shows at what time the user first opened the site, how many seconds s/he stayed in that page, and how many times s/he clicked on the page. Here's a sandbox of what happens below.

counter.init.ts

Here we define our initial state, this should be self-explanatory, so I won't go into it.

import { TCounterState } from "./counter.type";

export const initialState: TCounterState = {
  visit: {
    init: new Date(),
    duration: 0
  },
  clicks: {
    left: 0,
    right: 0
  }
};
Enter fullscreen mode Exit fullscreen mode

counter.context.tsx

This one is easy too, we just defined context:

...imports...

export const CounterContext = createContext<TCounterContext>({
  state: initialState,
  actions: null
});
Enter fullscreen mode Exit fullscreen mode

counter.provider.tsx

Here is where we actually initialize the context.

  1. First we make a reducer (using our rootReducer) and our initial state.
  2. Then we pass the dispatch function to a factory function which creates our actions for us. This is because we don't want to expose the inner structure of our reducers, as far as our global state users are concerned, they have some functions which they can call and supply the appropriate payloads as arguments.
  3. We provide our state and actions to the provider so it can "provide" them to whatever children we wrap inside it.
...imports...

const CounterProvider: FC = ({ children }) => {
  const [state, dispatch] = useReducer(rootReducer, initialState);
  const actions = createActions(dispatch);

  return (
    <CounterContext.Provider value={{ state, actions }}>
      {children}
    </CounterContext.Provider>
  );
};

export { CounterProvider };
Enter fullscreen mode Exit fullscreen mode

App.tsx

Here is where we wrap our components into this provider we just made:

...imports...

export default function App() {
  return (
    <CounterProvider>
      <Visit />
      <Clicks />
    </CounterProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

counter.type.ts

Here are all our types. Note that for our TCounterAction we provide a generic since our payloads can be different for different reducers.

import { Dispatch } from "react";

export type TCounterContext = {
  state: TCounterState;
  actions: Actions | null;
};

export type TCounterState = {
  visit: TVisit;
  clicks: TClicks;
};

export type TVisit = {
  init: Date;
  duration: number;
};

export type TClicks = {
  left: number;
  right: number;
};

export type CounterDispatch = Dispatch<TCoutnerAction<TRootPayload>>;

/////////////////////////////////////////////
// Reducer Actions                         //
/////////////////////////////////////////////

export type TCoutnerAction<TPayload> = {
  type: string;
  payload?: TPayload;
};

export type Actions = {
  visit: VisitActions;
  clicks: ClicksActions;
};

export type VisitActions = {
  setInit: (init: Date) => void;
  incrementDuration: (increment: number) => void;
};

export type ClicksActions = {
  incrementLeft: (increment: number) => void;
  incrementRight: (increment: number) => void;
};

/////////////////////////////////////////////
// Reducer Payloads                        //
/////////////////////////////////////////////

export type TVisitPayload = {
  init?: Date;
  increment?: number;
};

export type TClicksPayload = {
  increment: number;
};

export type TRootPayload = TVisitPayload | TClicksPayload;
Enter fullscreen mode Exit fullscreen mode

./reducers/index.ts AKA rootReducer

This root reducer basically wraps all our reducers and forwards the actions to them and aggregates all the states and actions. We separate reducers to respect the "single responsibility" principle and lower our coupling.

One thing I should mention is that we can add a reducerType property or something similar on our payloads to prevent casting our types (see * in the code below), and instead narrow our types. However, since we expose action functions, the probability of users causing problems (sending mismatching action.types with bad payloads) is very low.

...imports...

export const rootReducer = (
  { visit, clicks }: TCounterState,
  action: TCoutnerAction<TRootPayload>
): TCounterState => ({
  visit: visitReducer(visit, action as TCoutnerAction<TVisitPayload>), // *
  clicks: clicksReducer(clicks, action as TCoutnerAction<TClicksPayload>) // *
});

export const createActions = (dispatch: CounterDispatch): Actions => ({
  visit: createVisitActions(dispatch),
  clicks: createClicksActions(dispatch)
});
Enter fullscreen mode Exit fullscreen mode

./reducers/visit.reducer.ts

Here is our visit reducer. We set the initial visit time and increment our duration of visit. We also create the corresponding actions for this reducer right in here (higher cohesion).

...imports...

export const visitReducer = (
  state: TVisit,
  action: TCoutnerAction<TVisitPayload>
): TVisit => {
  switch (action.type) {
    case "visit/init":
      return {
        ...state,
        init: action.payload?.init || state.init
      };
    case "visit/increment":
      return {
        ...state,
        duration: state.duration + (action.payload?.increment || 0)
      };
    default:
      return state;
  }
};

export const createVisitActions = (
  dispatch: CounterDispatch
): VisitActions => ({
  setInit: (init: Date) => dispatch({ type: "visit/init", payload: { init } }),
  incrementDuration: (increment: number) =>
    dispatch({ type: "visit/increment", payload: { increment } })
});
Enter fullscreen mode Exit fullscreen mode

./reducers/clicks.reducer.ts

Similarly, we handle our clicks through the clicks reducer. I decided to keep this one with increment actions that accept a value just so that I could show you how the different payloads are handled. Otherwise we would've had only one payload type (for visits).

...imports...

export const clicksReducer = (
  state: TClicks,
  action: TCoutnerAction<TClicksPayload>
): TClicks => {
  switch (action.type) {
    case "clicks/left":
      return {
        ...state,
        left: state.left + (action.payload?.increment || 0)
      };
    case "clicks/right":
      return {
        ...state,
        right: state.right + (action.payload?.increment || 0)
      };
    default:
      return state;
  }
};

export const createClicksActions = (
  dispatch: CounterDispatch
): ClicksActions => ({
  incrementLeft: (increment: number) =>
    dispatch({ type: "clicks/left", payload: { increment } }),
  incrementRight: (increment: number) =>
    dispatch({ type: "clicks/right", payload: { increment } })
});
Enter fullscreen mode Exit fullscreen mode

./components/visit.tsx

And here's how we handle updating our state (with action functions) and we also show the values on the screen.

...imports...

const Visit: FC = () => {
  const counterContext = useContext(CounterContext);

  useEffect(() => {
    counterContext.actions?.visit.setInit(new Date());
    const i = setInterval(() => {
      counterContext.actions?.visit.incrementDuration(10);
    }, 10 * 1000);

    return () => clearInterval(i);
  }, []);

  return (
    <div>
      <h1>Visit:</h1>
      <p>Initial visit time: {counterContext.state.visit.init.toString()}</p>
      <p>You've been here for: {counterContext.state.visit.duration} seconds</p>
    </div>
  );
};

export { Visit };
Enter fullscreen mode Exit fullscreen mode

./components/clicks.tsx

Similarly for our clicks component:

...imports...

const Clicks: FC = () => {
  const counterContext = useContext(CounterContext);

  useEffect(() => {
    document.addEventListener("contextmenu", function (e) {
      e.stopImmediatePropagation();
      e.preventDefault();
      counterContext.actions?.clicks.incrementRight(2);
    });
    document.addEventListener("click", (e) => {
      e.stopImmediatePropagation();
      e.preventDefault();
      counterContext.actions?.clicks.incrementLeft(1);
    });
  }, []);

  return (
    <div>
      <h1>Clicks:</h1>
      <p>Left: {counterContext.state.clicks.left}</p>
      <p>Right: {counterContext.state.clicks.right}</p>
    </div>
  );
};

export { Clicks };
Enter fullscreen mode Exit fullscreen mode

Conclusion

This way, if and when our state grows, we can handle different sections in a modular fashion and also have a clean boundary between our slices (hello Redux, How u doin'?). Let me know what you think in the comments :)

Image Source

Discussion (2)

Collapse
jcubic profile image
Jakub T. Jankiewicz

Your code is hard to read, you can use syntax highlighting on DEV.to:

Markdown code for code snippets

it will render as

function hello() {

}
Enter fullscreen mode Exit fullscreen mode
Collapse
mazharzandsalimi profile image
Mazhar Zandsalimi Author

Oh right! Thank you so much, I was wondering why it wasn't syntax highlighting. I'll fix them right away :)