๐ Elevating React Context: Stay Maintainable
SanariSan
Posted on August 8, 2023
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>
);
};
...
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]);
...
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 ( ... );
};
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 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
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 ( ... );
};
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>;
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);
};
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),
[]
);
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") => {
...
}
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 individualeffects
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 ( ... );
};
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;
}
...
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;
}>;
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!
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:
// 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:
// 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!
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:
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>;
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;
};
context.tsx
import { createContext } from "react";
import { TContext } from "./context.type";
export const ValueContext = createContext({} as TContext);
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>
);
};
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 ๐
Posted on August 8, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.