DEV Community

Cover image for 🚀 Elevating React Context: Stay Maintainable
SanariSan
SanariSan

Posted on

🚀 Elevating React Context: Stay Maintainable

Introduction

This is my first writing. I would love to hear feedback! 🙃

So, recently I joined a new team as a full-stack dev, but decided to focus on frontend first. What hurt me the most was the lack of a store management system like redux/mobx/..., native Context was used instead for each and every part of data movement across the app.

I'm alright with any solution until it's done with good intentions and maintainability in mind, unfortunately, that was not the case. Huge files with hundreds of lines of callbacks mutating state, useEffects in between, no memoization whatsoever...

A new section, with a new context slice, was planned to be written. Here's my chance to make things better, I thought. Whether it will be used in its current state or modified in some way is unknown to me at this point, but I hope that we figure that out soon enough and agree on such a solution to be used across the whole codebase, including refactoring of old Contexts.

Throughout this post, the showcase repo is used for code references. In this repo I show how I turned totally unmaintainable spaghetti Contexts into something splittable and predictable in 4 steps. It is also hosted on CodeSandbox, so feel free to poke around, try your own ideas, and share them in the comments. I would love to see those!

Also, here's another codesandbox where I show the final bare minimum setup (also mentioned at the end of the article).


To start with

First of all, I had little to no experience with the Context API apart from trying it in a test project once, so I'm not aware of best practices or how big-tech uses it (afaik Facebook is written on Context, isn't it 😅). Nevertheless, I've worked with redux/thunk/saga and enjoyed the concepts they showed me.

Main goal

Since moving the whole project to some store lib would be almost impossible, I had a simple idea in mind - make usage as close to the native context as possible, which means preferably no reducers, no actions, no fancy reselected selectors, etc...

With that in mind...


Main action

Setup

The showcase project is a template built around a human and his demand for food and water. When a button is clicked, the daytime changes to morning/evening/night, which forces the human to consume something. This is achieved through the use of Context.

In the code examples, some variables and other minor aspects might be omitted, so please check out the full repo if you need to.


1: Raw Context

First raw Context example looks like this:

sandbox - provider
sandbox - component

Context provider:

...

const HumanNeedsProviderRaw = ({
  children,
}: {
  children: ReactNode;
}) => {
  const [pastaKg, setPastaKg] = useState(5);
  const [saladKg, setSaladKg] = useState(5);
  const [waterL, setWaterL] = useState(5);

  // won't even work and trigger infinite loop from start

  // const drink = () => {
  //   log("Drinking as a side effect...");
  //   setWaterL((waterL) => waterL - 1);
  // };

  // useEffect(() => {
  //   drink();
  // }, [drink]);

  const eat = (foodType: "salad" | "pasta") => {
    switch (foodType) {
      case "salad": {
        log("eating 1kg of salad");
        setSaladKg((saladKg) => saladKg - 1);
        break;
      }
      case "pasta": {
        log("eating 1kg of pasta");
        setPastaKg((pastaKg) => pastaKg - 1);
        break;
      }
      default: {
        // ...
      }
    }

    log("Don't forget to drink!");
  };

  return (
    <HumanNeedsContextRaw.Provider
      value={{
        waterL,
        pastaKg,
        saladKg,
        eat,
      }}
    >
      {children}
    </HumanNeedsContextRaw.Provider>
  );
};

...
Enter fullscreen mode Exit fullscreen mode

Component:

The eatCounter is present just to prevent an infinite rerender crash.

...

const HumanComponentRaw: FC<THumanComponent> = () => {
  const eatCounter = useRef(0);
  const { saladKg, pastaKg, waterL, eat } = useContext(HumanNeedsContextRaw);
  const [stateOfTheDayIdx, setStateOfTheDayIdx] = useState(0);
  const stateOfTheDay = statesOfTheDay[stateOfTheDayIdx];

  const rotateTime = () => {
    setStateOfTheDayIdx((stateOfTheDayIdx) =>
      stateOfTheDayIdx === statesOfTheDay.length - 1 ? 0 : stateOfTheDayIdx + 1
    );
  };

  useEffect(() => {
    if (eatCounter.current >= 20) {
      log("we are dead");
      return;
    }

    if (stateOfTheDay === "morning") {
      eat("salad");
    } else if (stateOfTheDay === "evening") {
      eat("pasta");
    } else {
      // ... sleep ...
    }

    eatCounter.current += 1;
  }, [stateOfTheDay, eat]);

...
Enter fullscreen mode Exit fullscreen mode

Component will look almost the same until the last stage. Let's focus on improving the Context.

Issues with the current implementation:

  • Not memoized with useCallback.
  • Can't use useEffect for side actions because of the line above
  • All state mutation functions are in one file, and moreover, in one Context provider component body.

Let's stop with this infinite list of issues and move on to the second stage, where callbacks are at least memoized properly according to best practices.


2: Memoized

sandbox - component
sandbox - provider

Component is exactly the same, but without eatCounter, no crash incoming 🙂

What about Context?
It changed slightly:

Context provider

...

const HumanNeedsProviderMemoized = ({
  children,
}: {
  children: ReactNode;
}) => {
  const [pastaKg, setPastaKg] = useState(5);
  const [saladKg, setSaladKg] = useState(5);
  const [waterL, setWaterL] = useState(5);

  const mountedRef = useRef(false);

  const drink = useCallback(() => {
    log("Drinking as a side effect...");
    setWaterL((waterL) => waterL - 1);
  }, []);

  useEffect(() => {
    if (!mountedRef.current) return;
    drink();
  }, [drink, saladKg, pastaKg]);

  const eat = useCallback((foodType: "salad" | "pasta") => {
    switch (foodType) {
      case "salad": {
        log("eating 1kg of salad");
        setSaladKg((saladKg) => saladKg - 1);
        break;
      }
      case "pasta": {
        log("eating 1kg of pasta");
        setPastaKg((pastaKg) => pastaKg - 1);
        break;
      }
      default: {
        // ...
      }
    }

    log("Don't forget to drink!");
  }, []);

  useEffect(() => {
    mountedRef.current = true;
    return () => void (mountedRef.current = false);
  }, []);

  return ( ... );
};
Enter fullscreen mode Exit fullscreen mode

First of all, mountedRef is used here, and this is just for showcase purposes to cut out pre-mount strict mode render. Here's the docs on that and how to avoid the issue, but I did it in my own manner, which is just enough to get it to work. Don't use it in production to avoid unexpected behavior.

Moving on, the app will now not crash after the first button press, and you can see how we can trigger the side useEffect to drink some water after eating. Sweet, that works!

Most people just leave it like that, keep stacking code in the same way until they sit in front of their laptops for hours, scrolling infinite lines of code, trying to track down how that value got into the Context state, what caused what, and so on... 💀

Let's write down the current issues:

  • Hard to track mutations.
  • useEffect is used, while the docs are kind of against it.
  • All functions are still in one file, which is okay for two of those, but not ideal for real-world scenarios.
  • Not able to check state inside the function before mutating it.

Let's stop on the last one before moving to the next stage. The human decided to eat, pressed the button so the machine can give him food.

What he expects:

  • The machine checks the food amount.
  • It gives food and reduces the amount if available, or tells there's no more food.

What happens:

  • The machine loans food from the universe and goes negative. 😅

That's because if we want to check the food inside the useCallbacked function, we need to pass this kind of food as a dependency. Updating it will cause a rerender, and since our callback is used in the user Component's useEffect, the app will crash due to infinite loop. This is something to be fixed; keep reading! ✌️


3: Memoized & Splitted

sandbox - component
sandbox - provider

Component is again the same, but Context got something interesting 👀. Callbacks, called effects, are now splitted into their own files!

Main goal for this step is to split.

Here's how that looks:

Context provider:

...

const HumanNeedsProviderMemoizedSplitted = ({
  children,
}: {
  children: ReactNode;
}) => {
  const mountedRef = useRef(false);

  const [pastaKg, setPastaKg] = useState(5);
  const [saladKg, setSaladKg] = useState(5);
  const [waterL, setWaterL] = useState(5);

  const drinkSplitted = useCallback(
    () => drinkSideEffect(setWaterL, mountedRef.current)(),
    []
  );
  useEffect(drinkSplitted, [drinkSplitted, saladKg, pastaKg]);

  const eat = useCallback(
    (foodType: "salad" | "pasta") =>
      eatEffect(setSaladKg, setPastaKg)(foodType),
    []
  );

  useEffect(() => {
    mountedRef.current = true;
    return () => void (mountedRef.current = false);
  }, []);

  return ( ... );
};
Enter fullscreen mode Exit fullscreen mode

Effects: Eat

export const eatEffect =
  (
    setSaladKg: (value: React.SetStateAction<number>) => void,
    setPastaKg: (value: React.SetStateAction<number>) => void
  ) =>
  (foodType: "salad" | "pasta") => {
    switch (foodType) {
      case "salad": {
        log("eating 1kg of salad");
        setSaladKg((saladKg) => saladKg - 1);
        break;
      }
      case "pasta": {
        log("eating 1kg of pasta");
        setPastaKg((pastaKg) => pastaKg - 1);
        break;
      }
      default: {
        // ...
      }
    }

    log("Don't forget to drink!");
  };

type TEatEffect = typeof eatEffect;
export type TEat = ReturnType<TEatEffect>;
Enter fullscreen mode Exit fullscreen mode

Side-Effects: Drink

export const drinkSideEffect =
  (
    setWaterL: (value: React.SetStateAction<number>) => void,
    isMounted: boolean
  ) =>
  () => {
    if (!isMounted) return;
    log("Drinking as a side effect...");
    setWaterL((waterL) => waterL - 1);
  };
Enter fullscreen mode Exit fullscreen mode

It might look like too much for one step, but let's dig into what actually happened here and what problems were solved.

First, mountedRef is still here, bad practice, don't use it, okay? 😄 We'll get rid of that in the last stage, but for now, it serves as a mechanism, preventing unnecessary state mutations on mount.

Moving to the splitting. We all know how to import and export, so why don't we do that with callbacks?

The requirements for a callback are the following:

  • Receive an argument from the user.
  • Mutate internal context state based on it.

Well, let's just pass all the pieces it needs from context!

const eat = useCallback(
    (foodType: "salad" | "pasta") =>
      eatEffect(setSaladKg, setPastaKg)(foodType),
    []
);
Enter fullscreen mode Exit fullscreen mode

Currying is used here to make things more organized and easily dividable.

The first call contains all arguments related to the current state and its mutation. If we wanted to pass some state (like waterL), it'd go here.

The second call contains all the arguments desired from the user, nothing more to add here.

const eatEffect =
  (
    setSaladKg: (value: React.SetStateAction<number>) => void,
    setPastaKg: (value: React.SetStateAction<number>) => void
  ) =>
  (foodType: "salad" | "pasta") => {
    ...
  }
Enter fullscreen mode Exit fullscreen mode

Types for arguments are just straight copied from the set*Name* call type hint, as easy as that.

Following the same principle, our side effect was split into a separate file. We'll get rid of the side effect later, but for this step, the main goal was to split.

What problems were solved:

  • Callbacks are now splittable, resulting in a less polluted Context provider body, and improved folders/files structure (depends on you).
  • Due to that, we became less bound to the Context provider and more focused on individual effects logic.

Still needs to be solved:

  • Nasty useEffect side actions.
  • Inability to access current state inside callbacks to perform basic checks.
  • The duty of passing each and every user variable used in every effect, which is extremely annoying if you ask me. 😅

Dear user, if you are still with me at this point, I'm grateful for your time and patience. 🙏


4: Memoized Reactive ref

sandbox - component
sandbox - provider

It was a long journey to reach this point. Here, I'm going to show you what I've come up with to solve the rest of the problems, and the solution is simple yet elegant. 🙂

First, the code:

Context provider

const HumanNeedsProviderMemoizedReactive = ({
  children,
}: {
  children: ReactNode;
}) => {
  const [pastaKg, setPastaKg] = useState<TState["pastaKg"]>(5);
  const [saladKg, setSaladKg] = useState<TState["saladKg"]>(5);
  const [waterL, setWaterL] = useState<TState["waterL"]>(5);

  // --- selectors

  const foodCombined: TFoodCombined = useMemo(
    () => foodCombinedSelector({ pastaKg, saladKg })(),
    [pastaKg, saladKg]
  );

  // --- reactive setup

  const reactive = useRef({}) as TReactive;
  reactive.current = {
    state: {
      pastaKg,
      saladKg,
      waterL,
    },
    selectors: {
      foodCombined,
    },
    setters: {
      setPastaKg,
      setSaladKg,
      setWaterL,
    },
  };

  // --- effects

  const eat: TEat = useCallback((...args) => eatEffect(reactive)(...args), []);
  const req: TReq = useCallback((...args) => reqEffect(reactive)(...args), []);
  // const etc: TEat = useCallback((...args) => etcEffect(reactive)(...args), []);

  return ( ... );
};
Enter fullscreen mode Exit fullscreen mode

Effects: Eat (partial)

...

export const eatEffect =
  (reactive: TReactive) => (foodType: "salad" | "pasta") => {
    const {
      setters: { setSaladKg, setPastaKg, setWaterL },
      state: { pastaKg, saladKg, waterL },
      selectors: { foodCombined },
    } = reactive.current;

    log(`Food combined: ${foodCombined}`);

    switch (foodType) {
      case "salad": {
        if (saladKg <= 0) {
          log("No salad left");
          return;
        }

        log("eating 1kg of salad");
        setSaladKg((saladKg) => saladKg - 1);
        break;
      }

...
Enter fullscreen mode Exit fullscreen mode

Types: TReactive

export type TState = {
  pastaKg: number;
  saladKg: number;
  waterL: number;
};

export type TSelectors = {
  foodCombined: number;
};

export type TSetters = {
  setPastaKg: Dispatch<SetStateAction<number>>;
  setSaladKg: Dispatch<SetStateAction<number>>;
  setWaterL: Dispatch<SetStateAction<number>>;
};

export type TReactive = MutableRefObject<{
  state: TState;
  selectors: TSelectors;
  setters: TSetters;
}>;
Enter fullscreen mode Exit fullscreen mode

And there are a couple of other interesting files, but let's inspect these!

If you still remember, the problems were:

  • Nasty useEffect side actions.
  • Inability to access the current state inside callbacks to perform basic checks.
  • The duty of passing each and every variable used in every effect.

And they are all gone!

First of all, there's a new creature in our Context provider - reactive. It's a ref containing all context-related fields, including the new selectors concept, but all things in order.

Our ref is updated on every rerender, and since rerenders in the provider are triggered only by calling some of setStates (putting the provider wrapper on the top level of your app, right? 🤨), there will be absolutely no performance impact (except maybe excessive amount of old objects from ref, which are not referenced anymore, but I pass this one to garbage collector).

Moving on, we pass this reactive ref containing the most fresh state into every effect, where we do dereferencing by destructuring values locally. This way we obtain a snapshot of reactive object.
From this point, the effect will work with stable values that were valid at the moment of the callback call.

If, for some reason during the callback execution, you need to access live state, just dereference reactive again for a new snapshot; it's dead simple!

const {
  state: { waterL },
} = reactive.current;

// making req, sleeping, etc
// someone maybe drank some water in background?

const {
  state: { waterL: waterLNew },
} = reactive.current;

// access live waterL value with waterLNew, just like that!
Enter fullscreen mode Exit fullscreen mode

This is huge ❗❗
Now we can access the live state, each and every state setter, and don't have ANY dependencies in useCallback, neat!

Moreover, if we can access everything from here, why even need useEffect side effects anyway 😮

Want to drink after eating? Here you go:

// now can even check water state from snapshot
if (waterL <= 0) {
  log("no more water left");
  return;
}
log("Drinking inline...");
setWaterL((waterL) => waterL - 1);
Enter fullscreen mode Exit fullscreen mode

Want to drink in a separate effect? My pleasure:

// split all the logic in separate effect and call in hierarchy
drinkEffect(reactive)();
Enter fullscreen mode Exit fullscreen mode

As you can see, now the workflow is clear, and you know for sure what happened after what.

What about the annoying duty of passing user args to these callbacks? Not a problem anymore!

const eat: TEat = useCallback((...args) => eatEffect(reactive)(...args), []);
const req: TReq = useCallback((...args) => reqEffect(reactive)(...args), []);
// const etc: TEat = useCallback((...args) => etcEffect(reactive)(...args), []);
Enter fullscreen mode Exit fullscreen mode

Look at these beautifully crafted oneliners; don't open your Context provider file anymore, go straight into the effect file!

These shorthands are all your Context provider needs to hold inside, all the logic and mutations conveniently split into their own structured files. How cool is that? 🎉 🎉 🎉

There are some typings for TReactive in a separate file, type inference for effects, a showcase of request on mounting using AbortController 👍 — all the basic concepts, just mentioning. Inspect the files, and you'll tune in fast.

Also, there's a selector, not that fancy redux selector, but simply a memoized value based on state pieces. I'd say it's pretty convenient, including the fact that it's also splittable 🎉


To sum up

Here's the bare minimum you need to setup context:

HOSTED on CodeSandbox too!

change.effect.ts

import { TReactive } from "./context.type";

export const changeEffect = (reactive: TReactive) => 
(smthFromUser?: unknown) => {
  const {
    setters: { setValue },
    state: { value }
  } = reactive.current;

  setValue(0);
};

export type TChangeEffect = typeof changeEffect;
export type TChange = ReturnType<TChangeEffect>;
Enter fullscreen mode Exit fullscreen mode

context.type.ts

import { Dispatch, MutableRefObject, SetStateAction } from "react";
import { TChange } from "./change.effect";

export type TState = {
  value: number;
};

export type TSetters = {
  setValue: Dispatch<SetStateAction<number>>;
};

export type TReactive = MutableRefObject<{
  state: TState;
  setters: TSetters;
}>;

export type TContext = {
  value: TState["value"];
  change: TChange;
};
Enter fullscreen mode Exit fullscreen mode

context.tsx

import { createContext } from "react";
import { TContext } from "./context.type";

export const ValueContext = createContext({} as TContext);
Enter fullscreen mode Exit fullscreen mode

provider.tsx

import { ReactNode, useCallback, useRef, useState } from "react";
import { changeEffect, TChange } from "./change.effect";
import { ValueContext } from "./context";
import { TReactive, TState } from "./context.type";

export const ValueProvider = ({ children }: { children: ReactNode }) => {
  const [value, setValue] = useState<TState["value"]>(1);

  // --- reactive setup

  const reactive = useRef({}) as TReactive;
  reactive.current = {
    state: { value },
    setters: { setValue }
  };

  // --- effects

  const change: TChange = useCallback(
    (...args) => changeEffect(reactive)(...args),
    []
  );

  return (
    <ValueContext.Provider value={{ value, change }}>
      {children}
    </ValueContext.Provider>
  );
};
Enter fullscreen mode Exit fullscreen mode

End notes:

I know there could be a solution like that, maybe it's even widely known, but I never heard of one and worked my way from a pure set of problems to something usable, structured, and convenient. I hope this helps anyone who is also decided to live with context but struggling with the drawbacks of one. 🫠

Context is by no means a solution for storing data. You either use it for simple props-drilling evasion in a couple of places or switch to real store solution. Inventing things like that or using useReducer won't lead you anywhere but to the land of pain. I hope the current approach is good enough to fulfill our requirements, but again, consider redux/mobx if you need a store, don't make others' mistakes 🙂

Top comments (3)

Collapse
 
giovannimazzuoccolo profile image
Giovanni Mazzuoccolo

Context is by no means a solution for storing data. You either use it for simple props-drilling evasion in a couple of places or switch to real store solution.

True. it is useful only for data that almost never change, like dark mode for example

Inventing things like that or using useReducer won't lead you anywhere but to the land of pain. I hope the current approach is good enough to fulfill our requirements,

Don't fully agree with this. useReducer is great to keep things organised if you need to change multiple statuses at the same time. Some of the code that you shared can be easily refactored with useReducer. But it's not good for the global state for sure.

but again, consider redux/mobx if you need a store, don't make others' mistakes

Agree with that 100% :)

Collapse
 
sanarisan profile image
SanariSan • Edited

I saw a couple of solutions making use of useReducer + custom actions concept to sort of replicate dispatching to the store, but it seemed too much for me. 🤷‍♂️

Collapse
 
Sloan, the sloth mascot
Comment deleted