This Is Tuple Context Pattern
April 5th, 2025 | 8 mins readPublished on dev.to and medium.com
Hello There 👋
I'm back with a brand new post for React and Preact developers, and especially anyone leveraging the Context API feature.
The approach I'm about to present is designed to make Context usage in a large-scale application more robust and easy-to-use. In this post, we assume that you're using just one Context as the single source of truth to handling business data, but this trick can be adapted to multiple Contexts as well.
Without any further delay, let me introduce you to my pattern, which I have modestly named "Tuple Context"", inspired by a practice I discovered while working with the Solid framework.
How To Context...
Let's go step by step, recalling how to create a Context with the React library ⚛
import { createContext, useContext, useState } from 'react';
const AppContext = createContext(null);
const initialUser = {
firstName: '',
lastName: '',
email: '',
roles: []
};
export default function AppProvider({ children }) {
const [counter, setCounter] = useState(0);
const incrementCounter = () => setCounter(value => value + 1);
const decrementCounter = () => setCounter(value => value - 1);
const resetCounter = () => setCounter(0);
const [user, setUser] = useState(initialUser);
const fullName = `${user.firstName} ${user.lastName}`;
const setFirstName = firstName => setUser(u => ({ ...u, firstName }));
const setLastName = lastName => setUser(u => ({ ...u, lastName }));
const setEmail = email => setUser(u => ({ ...u, email }));
const addRole = role => setUser(u => ({ ...u, roles: [...u.roles, role] }));
const delRole = role => setUser(u => ({ ...u, roles: u.roles.filter(r => r !== role) }));
const resetUser = () => setUser(initialUser);
const ctxValues = {
counter,
setCounter,
incrementCounter,
decrementCounter,
resetCounter,
user,
fullName,
setUser,
setFirstName,
setLastName,
setEmail,
addRole,
delRole,
resetUser
};
return <AppContext.Provider value={ctxValues}>{children}</AppContext.Provider>;
}
export const useAppContext = () => useContext(AppContext);
Above, we declare a new Context (AppContext
) that exposes two reactive variables (counter
and user
), a derived reactive variable (fullName
), and functions to mutate the Context's state (such as setCounter
or setUser
).
We also assume that we are correctly using the declared <AppProvider />
component by wrapping the application, or at least the <Counter />
and <UserForm />
components (as shown in the example).
import { useAppContext } from './AppContext';
export default function Counter() {
const { counter, incrementCounter, decrementCounter } = useAppContext();
return (
<div className="my-auto flex flex-col gap-y-2">
<span>Counter Value : {counter}</span>
<div className="mx-auto flex flex-row gap-x-4">
<button
type="button"
onClick={incrementCounter}>
+1
</button>
<button
type="button"
onClick={decrementCounter}>
-1
</button>
</div>
</div>
);
}
Then, in practice, from the <Counter />
component, we fetch the values relating to the counter
data (whether it's a reactive variable, or a state mutation function), to feed the render template, especially for the events of the "+1/-1" buttons.
NB : Here,
useAppContext
acts as a shortcut to inject Context values from inside the component, avoiding explicit imports ofAppContext
anduseContext
from this last one.
import { useAppContext } from './AppContext';
function UserForm({ handleSubmit }) {
const { fullName, setFirstName, setLastName, resetUser } = useAppContext();
return (
<form
className="my-auto flex flex-col gap-y-2"
onSubmit={handleSubmit}>
<span>FullName : {fullName}</span>
<div className="flex flex-col gap-y-2">
<label htmlFor="firstName">FirstName</label>
<input
id="firstName"
type="text"
onChange={e => setFirstName(e.target.value)}
/>
</div>
<div className="flex flex-col gap-y-2">
<label htmlFor="lastName">LastName</label>
<input
id="lastName"
type="text"
onChange={e => setLastName(e.target.value)}
/>
</div>
<div className="mx-auto flex flex-row gap-x-4">
<button
type="button"
onClick={resetUser}>
Cancel
</button>
<button type="submit">Submit</button>
</div>
</form>
);
}
In the same way as for the previous component (but starting from <UserForm />
), we fetch values related to the user
data without worrying about the organization of the Context.
Indeed, since it's just a JSON object, we can get the user
and fullName
variables, as well as the setFirstName
, setLastName
, setEmail
, addRole
, delRole
or resetUser
functions without any complexity.
Introducing: The Tuple Context Pattern
The weakness of this design lies in the value provided to the Context.Provider
, or rather its structure... "It's just a JSON object"*! As a result, as your application evolves, it may host an increasing number of values (including 5 main reactive variables, 10 derived variables, and 2 dozens of mutation functions), all placed at the same level.
That's where the "Tuple Context" pattern comes into play!
export default function AppProvider({ children }) {
const [counter, setCounter] = useState(0);
const incrementCounter = () => setCounter(value => value + 1);
const decrementCounter = () => setCounter(value => value - 1);
const resetCounter = () => setCounter(0);
const [user, setUser] = useState(initialUser);
const fullName = useMemo(() => `${user.firstName} ${user.lastName}`, [user]);
const setFirstName = firstName => setUser(u => ({ ...u, firstName }));
const setLastName = lastName => setUser(u => ({ ...u, lastName }));
const setEmail = email => setUser(u => ({ ...u, email }));
const addRole = role => setUser(u => ({ ...u, roles: [...u.roles, role] }));
const delRole = role => setUser(u => ({ ...u, roles: u.roles.filter(r => r !== role) }));
const resetUser = () => setUser(initialUser);
const ctxValues = {
counter,
user,
fullName
};
const ctxDispatchers = {
setCounter,
incrementCounter,
decrementCounter,
resetCounter,
setUser,
setFirstName,
setLastName,
setEmail,
addRole,
delRole,
resetUser
};
return <AppContext.Provider value={[ctxValues, ctxDispatchers]}>{children}</AppContext.Provider>;
}
As its name suggests, this approach structures the data exposed by the Context, like a fixed-size array (similar to useState
).
So, the array is structured into two elements:
- The 1st parameter exposes constants/reactive variables;
- The 2nd parameter exposes functions that mutate the Context state;
export default function Counter() {
- const { counter, incrementCounter, decrementCounter } = useAppContext();
+ const [{ counter }, { incrementCounter, decrementCounter }] = useAppContext();
return {/* ... */}
}
The idea is pretty simple, isn't it? Nothing revolutionary, just a little trick that makes sense 😉
In practice, the Tuple parameters can be used independently:
- Either extract the left-hand operand exclusively, when you just need to access the Context values;
- Or extract the right-hand operand (omitting the left one), when you need to update these values;
NB: In this second case, I recommend using the "underscore" convention to specify that it is an unused parameter, and to benefit from better readability:
const [_, { setCounter, setUser }] = useAppContext()
export default function UserForm() {
- const { fullName, setFirstName, setLastName, resetUser } = useAppContext();
+ const [{ fullName }, { setFirstName, setLastName, resetUser }] = useAppContext();
return {/* ... */}
}
In JavaScript, this requires some logic... On the other hand with TypeScript, it's a real pleasure!
TypeScript Forever 💙
At first glance, typing this kind of Context isn't straightforward (it's less intuitive than a simple JSON object)... However, it's essential to properly define the data types to make exposed values easier to use, as well as to make the import of reactive variables (or mutation functions - or both 🙃) inside components more robust.
import React, { createContext, type ReactNode, useContext, useMemo, useState } from 'react';
interface User {
firstName: string;
lastName: string;
email: string;
roles: [];
}
interface AppContextState {
counter: number;
user: User;
fullName: string;
}
interface AppContextDispatchers {
setCounter: React.Dispatch<React.SetStateAction<number>>;
incrementCounter: () => void;
decrementCounter: () => void;
resetCounter: () => void;
setUser: React.Dispatch<React.SetStateAction<User>>;
setFirstName: (value: string) => void;
setLastName: (value: string) => void;
setEmail: (value: string) => void;
addRole: (value: string) => void;
delRole: (value: string) => void;
resetUser: () => void;
}
const AppContext = createContext<[AppContextState, AppContextDispatchers] | null>(null);
const initialUser = {
firstName: '',
lastName: '',
email: '',
roles: []
};
interface AppProviderProps {
children: ReactNode;
}
export default function AppProvider({ children }: AppProviderProps) {
const [counter, setCounter] = useState(0);
const incrementCounter = () => setCounter(value => value + 1);
const decrementCounter = () => setCounter(value => value - 1);
const resetCounter = () => setCounter(0);
const [user, setUser] = useState(initialUser);
const fullName = useMemo(() => `${user.firstName} ${user.lastName}`, [user]);
const setFirstName = firstName => setUser(u => ({ ...u, firstName }));
const setLastName = lastName => setUser(u => ({ ...u, lastName }));
const setEmail = email => setUser(u => ({ ...u, email }));
const addRole = role => setUser(u => ({ ...u, roles: [...u.roles, role] }));
const delRole = role => setUser(u => ({ ...u, roles: u.roles.filter(r => r !== role) }));
const resetUser = () => setUser(initialUser);
const ctxValues = {
counter,
user,
fullName
};
const ctxSetters = {
setCounter,
incrementCounter,
decrementCounter,
resetCounter,
setUser,
setFirstName,
setLastName,
setEmail,
addRole,
delRole,
resetUser
};
return <AppContext.Provider value={[ctxValues, ctxSetters]}>{children}</AppContext.Provider>;
}
export const useAppContext = () => {
const ctx = useContext(AppContext);
if (!ctx) {
throw new Error('useAppContext must be used within an AppProvider');
}
return ctx;
};
That's all, folks!
Let me know if you find this idea useful, or if you're already using this pattern in your applications. For the rest, it's up to you 😎👌