Engineering
Two arrows, never one bus
A lesson player has 'player state': the input, the highlighted line, the log, whether it's playing. The obvious build is one context every surface reads. I used two instead, pointing opposite ways — the learner's input flows in, the animation's position flows out — because a React context re-renders every subscriber on every change, and those two flows move at wildly different speeds.
By the time a bubble sort is playing, four surfaces are describing the same instant. The canvas shows the compared pair. The code panel lights up if (a[j] > a[j + 1]). The status line reads comparing 7 and 3. The log gains a row. And off to the side sits an Inputs panel holding the array the learner typed, waiting for them to change it and run again. All of that is "the state of the player," and the obvious way to hold it is one place: a usePlayer() context every surface reads. I built two instead, pointed in opposite directions, and this post is why.
None of this is about the canvas itself. That's a frame-pure component that takes props — it renders from a frame number and the input, and it never subscribes to any of this state. What follows is about the wiring around it: getting the learner's input in, and getting the animation's position back out.
"Player state" is one noun hiding two directions
Line up what's actually in that blob and it splits cleanly by which way the data moves.
Some of it flows into the animation. The array the learner typed, the variant they picked, a recipe they applied — the config panel writes it, and the animation reads it to decide what to draw. Input. UI → animation.
The rest flows out of the animation. Which lines to highlight, the current phase, the status label, the row to append to the log — the animation is the only thing that knows these as it plays, and it hands them to the panels to display. Output. Animation → UI.
Those aren't two halves of one bag of state. They're two wires running in opposite directions, and the only reason they look like one thing is that they happen to live in the same player. Bag them together and you've soldered the input wire to the output wire because they came in the same color.
A React context has no volume knob
Here's the fact that turns "two wires" from a tidiness preference into an architecture: a React context re-renders every consumer whenever any part of its value changes. There is no selector, no "only wake me if activeLines moved." Read one field of a context and you've subscribed to all of it. (This is the exact hole zustand and jotai exist to fill.)
Now merge my two directions onto one bus and watch that rule turn on you. The animation emits a step (a new highlighted line), and because it's the same context that also holds the learner's input, the Inputs panel re-renders too. It didn't ask to. Nothing it shows changed. But the array editor, the variant tabs, and the recipe list all run their render again, because an unrelated field on the shared value ticked over. And it isn't a one-time cost: a playing animation emits a step every beat (about once a second at the default pace, faster on a compressed lesson or at 2×), so the whole config surface re-renders, once a second, to react to a value it never reads.
Split the bus back into two contexts and the same rule works for you. The input surfaces subscribe to the config context; the step broadcast lands on the other context, and a panel that reads only config never hears it. The panels that should move every step (code, status, log) subscribe to the output context, and a learner dragging the input slider doesn't ripple into them. Each surface wakes for its own wire and sleeps through the other. Same React rule, opposite outcome, and the only thing I changed was refusing to run two directions down one wire.
The output wire is one atomic push
The animation → UI side earns a second look, because it's the one that fires constantly and it's where a careless version bleeds renders. At each step boundary the animation makes a single call:
emitStep({ lines: [4], phase: "compare", label: "compare 7 and 3" });One call, and inside it the context sets the highlighted lines, the phase, and the status label, and appends the log row — several state writes React batches into a single render. The tempting build is to keep the label in its own state and grow the log with a useEffect that watches it. That version does the same work in two renders per step, and worse, it splits one semantic event (we're now on step 7) across two commits, so for one frame the code panel is on step 7 while the log is still on step 6. Bundling it into one explicit emitStep is what keeps the code, the status, and the log pinned to the same instant, which is the whole point of a debugger four surfaces read at once. That's not a render I'm shaving to feel clever; it's what makes "the current step" one fact instead of four states racing to agree on it.
Knowing when not to be reactive
The split has a sharper corollary I didn't see until I needed it: not everything flowing from the UI toward the animation should be state at all.
When a config panel applies a curated recipe whose payoff lives mid-trace, it can tell the player to seek to that phase instead of starting at frame 0. That's a UI → animation message, so it belongs on the config side. But make it context state and every config subscriber re-renders the moment a recipe is applied — to broadcast a hint that exactly one reader, the player's reset effect, consumes exactly once and then discards. So it doesn't ride the state channel. It rides a ref: the panel sets pendingSeekPhaseRef.current, the player reads and clears it on the next config reset, and nobody re-renders to carry a one-shot message to a single recipient.
That's the rule under the two arrows, stated properly. A context is a wire, and before you put a value on it you ask two things: which direction does it flow, and does anyone need to re-render when it changes? A frame-clock position: yes, the panels redraw. A fire-once seek hint: no, hand it over on a ref. Reach for context state by reflex and you've answered "yes" to a question you never asked.
The seam isn't free, and that's fine
Honesty check, because a clean diagram always hides one wire crossing where it shouldn't. The config panels do reach into the output context for one value: isPlaying, to gray out the variant tabs while an operation animates. Reading it re-subscribes those panels to the animation wire, so they do re-render every step during playback — the exact cost the split was supposed to spare them.
I let that one cross on purpose. A config panel is a handful of buttons, not a 1920×1080 canvas; re-running its render once a second costs nothing I can measure, and the canvas, the thing that would actually hurt, was never on a context wire to begin with. The alternative is worse: if the "disable while playing" flag lagged real playback by even a beat, a learner could click a variant tab mid-operation and desync the run. That's a correctness bug traded for a render I can't feel. So the seam is where I chose to pay. The rule was never "never cross the wires." It's "cross them where you can see it and defend it" — not because everything landed in one bag by default.
The third wire barely moves
There's a third context under both arrows, and it's worth naming precisely because it's the dull one. Before either wire means anything, something has to answer which lesson are we even in — load the definition, hand out its default input and code snippet, reset the world when the URL changes. That's the lesson context, wrapped outermost, and it ticks about as often as you'd guess: on navigation, and basically never otherwise. Nesting it on the outside is what lets the inner two assume a lesson exists (or cleanly resolve to "none" on a catalog page) without each re-checking.
So the player runs on three wires at three speeds. Which lesson: changes when the URL does. The input: changes when a human touches a knob. The position: changes every step of every playthrough. The mistake I didn't make — the tempting one, because it's all just player state — was to run all three down a single context and let a per-step broadcast wake a panel that only cared which lesson had loaded. Three clocks, three wires. The position broadcast is the fast one, and you do not let it twitch the knobs the learner is holding.