The two tools fit together into a small, self-contained store. A reducer owns the data and the transitions; one context publishes the state, a second publishes dispatch. Components that only trigger actions subscribe to the second context and are never re-rendered by a data change.
<div id="root"></div>
<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@8.0.5/babel.min.js"></script>
<script type="importmap">
{ "imports": { "react": "https://esm.sh/react@19.3.0",
"react-dom/client": "https://esm.sh/react-dom@19.3.0/client" } }
</script>
<script type="text/babel" data-type="module">
import React, { createContext, useContext, useReducer } from 'react';
import { createRoot } from 'react-dom/client';
const TasksContext = createContext(null), TasksDispatch = createContext(null);
function reducer(tasks, action) {
switch (action.type) {
case 'added': return [...tasks, { id: Date.now(), text: action.text }];
case 'cleared': return [];
default: throw new Error(action.type);
}
}
function TasksProvider({ children }) {
const [tasks, dispatch] = useReducer(reducer, [{ id: 1, text: 'Split the context' }]);
return <TasksContext value={tasks}>
<TasksDispatch value={dispatch}>{children}</TasksDispatch></TasksContext>;
}
function TaskList() { // reads, never dispatches
const tasks = useContext(TasksContext);
return <ul style={{ font: '16px system-ui', margin: '4px 0' }}>
{tasks.map(t => <li key={t.id}>{t.text}</li>)}</ul>;
}
function Toolbar() { // dispatches, never reads
const d = useContext(TasksDispatch);
return <><button onClick={() => d({ type: 'added', text: 'Ship it' })}>Add</button>{' '}
<button onClick={() => d({ type: 'cleared' })}>Clear</button></>;
}
createRoot(document.getElementById('root')).render(
<TasksProvider><TaskList /><Toolbar /></TasksProvider>);
setTimeout(() => document.querySelector('button').click(), 600); // scripted click
</script>
The second item in the screenshot was added by a scripted click on Add. Toolbar received no prop and never read the task list, yet it changed it; TaskList never learned where the data came from. Each component subscribes to exactly one of the two contexts, which is what keeps the toolbar out of every list re-render.
In a real project TasksProvider, the reducer and two one-line hooks — const useTasks = () => useContext(TasksContext) — live in one TasksContext.js module, and nothing outside it imports the raw contexts. That module is then the whole public interface of the feature: if it later becomes a Redux 3,824 slice (createSlice) or a Zustand 58,763 store, the components calling useTasks() do not change.