🚀 Elevating React Context: Stay Maintainable
Tags: react, typescript, context, showdev
Published: 2023-08-08 13:13:56

# 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](https://github.com/SanariSan/context-split/) is used for code references. In this [repo](https://github.com/SanariSan/context-split/) I show how I turned totally unmaintainable spaghetti `Contexts` into something _splittable_ and _predictable_ in 4 steps. It is also [hosted](https://codesandbox.io/s/small-feather-z9nxct) 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](https://codesandbox.io/s/stupefied-lumiere-zmdqnr) 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](https://github.com/SanariSan/context-split/) if you need to.

---

## 1: Raw `Context`

First `raw Context` example looks like this:

[sandbox - provider](https://codesandbox.io/s/small-feather-z9nxct?file=/src/context/human-needs/1-raw/human-needs.provider.tsx)
[sandbox - component](https://codesandbox.io/s/small-feather-z9nxct?file=/src/human/1-raw/human.component.tsx)

#### Context provider:

```tsx
...

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>
  );
};

...
```
#### Component:

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

```tsx
...

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]);

...
```

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](https://codesandbox.io/s/small-feather-z9nxct?file=/src/human/2-memoized/human.component.tsx)
[sandbox - provider](https://codesandbox.io/s/small-feather-z9nxct?file=/src/context/human-needs/2-memoized/human-needs.provider.tsx)

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

What about `Context`?
It changed slightly:

#### Context provider

```tsx
...

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 ( ... );
};
```

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](https://react.dev/learn/you-might-not-need-an-effect#initializing-the-application) 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](https://react.dev/learn/you-might-not-need-an-effect) 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 `useCallback`ed 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](https://codesandbox.io/s/small-feather-z9nxct?file=/src/human/3-memoized-splitted/human.component.tsx)
[sandbox - provider](https://codesandbox.io/s/small-feather-z9nxct?file=/src/context/human-needs/3-memoized-splitted/human-needs.provider.tsx)

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:

```tsx
...

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 ( ... );
};
```

#### Effects: Eat

```tsx
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>;
```

#### Side-Effects: Drink

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

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!

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

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.

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

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](https://codesandbox.io/s/small-feather-z9nxct?file=/src/human/4-memoized-reactive/human.component.tsx)
[sandbox - provider](https://codesandbox.io/s/small-feather-z9nxct?file=/src/context/human-needs/4-memoized-reactive/human-needs.provider.tsx)

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

```tsx
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 ( ... );
};
```

#### Effects: Eat (partial)

```tsx
...

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;
      }

...
```

#### Types: TReactive

```tsx
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;
}>;
```

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 `setState`s (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!

```tsx
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!
```

---

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:

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

Want to drink in a separate `effect`? My pleasure:

```tsx
// split all the logic in separate effect and call in hierarchy
drinkEffect(reactive)();
```

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!

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

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](https://codesandbox.io/s/stupefied-lumiere-zmdqnr) too!

#### change.effect.ts
```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>;
```

#### context.type.ts
```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;
};
```

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

export const ValueContext = createContext({} as TContext);
```

#### provider.tsx
```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>
  );
};
```
---

# 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 🙂