We put everything in one React context — streak, topic strengths, mistakes, quiz history, retry state, exam date, weekly scores — and for a long time it felt like good architecture. One place for all study state. Clean imports. No prop drilling.
Then we started tracing the system's behavior with Hindsight and realized we had built a monolith that re-rendered the entire app on every quiz answer, silently miscounted mastered topics after every session, and made it structurally impossible to connect related pieces of state without introducing stale closure bugs.
The context wasn't wrong. It was just doing too many things, and we couldn't see the consequences until we had a tool that observed behavior across sessions rather than one render at a time.
The Context: What It Holds
src/context/StudyContext.tsx is the backbone of the entire app. Every page that shows study data reads from it. Every page that produces study data writes to it. Here is the full shape of what it manages:
interface StudyState {
streak: number;
topicsMastered: number;
weakTopicsCount: number;
totalQuizzesTaken: number;
totalCorrect: number;
totalQuestions: number;
quizHistory: QuizResult[];
mistakes: MistakeEntry[];
topicStrengths: TopicStrength[];
weeklyScores: { day: string; score: number; quizzes: number }[];
examDate: Date;
// Actions
addQuizResult: (result: QuizResult, newMistakes: MistakeEntry[]) => void;
markMistakeReviewed: (id: number) => void;
retryMistake: (id: number) => void;
retryingMistakeId: number | null;
clearRetry: () => void;
}
Fourteen fields, five actions. The provider wraps the entire app in src/main.tsx, which means any component that consumes the context re-renders whenever any of these fourteen fields changes. A student answering a quiz question triggers addQuizResult, which updates streak, totalQuizzesTaken, totalCorrect, totalQuestions, quizHistory, mistakes, topicStrengths, topicsMastered, and weeklyScores — nine state updates in one function call, each of which can trigger re-renders in every consumer across the Dashboard, Quiz, Mistakes, and Planner pages simultaneously.
For this app's current scale that is tolerable. For a larger app it would be a performance problem. But the re-render issue is not the most interesting consequence of the monolithic context. The more interesting consequence is the stale closure bug it enabled.
The Bug That Context Made Possible
Inside addQuizResult, we update topic strengths and then derive a mastery count from them:
const addQuizResult = useCallback((result: QuizResult, newMistakes: MistakeEntry[]) => {
// ... other updates ...
// Correct: functional updater reads fresh state
setTopicStrengths((ts) =>
ts.map((t) => {
if (result.weakTopics.includes(t.topic)) {
return { ...t, strength: Math.max(10, t.strength - 8) };
}
return { ...t, strength: Math.min(100, t.strength + 2) };
})
);
// Bug: reads topicStrengths from stale closure
setTopicsMastered((prev) => {
const newStrengths = topicStrengths.map((t) =>
result.weakTopics.includes(t.topic) ? t.strength - 8 : t.strength + 2
);
return newStrengths.filter((s) => s >= 80).length;
});
}, [topicStrengths]); // topicStrengths in deps — but still stale inside the callback
The first setTopicStrengths uses the functional updater form (ts) => ... to read fresh state. The second setTopicsMastered reads topicStrengths directly from the closure. React batches state updates — setTopicStrengths has not flushed when setTopicsMastered executes, so the mastery count is always calculated from the previous quiz's strengths. After every quiz, the topic heatmap on the Dashboard updates correctly while the "Topics Mastered" counter is one quiz behind.
This bug exists because topicsMastered is stored as separate state when it should be derived from topicStrengths. We stored it separately because it felt like a dashboard stat — something to display prominently — rather than a computed property. The monolithic context made it easy to add a new useState for it without questioning whether it should be state at all. If topic strengths and mastery count had lived in separate, domain-specific contexts, the dependency would have been more obvious and the derived relationship harder to accidentally break.
What Hindsight Surfaces About Context Design
The stale closure bug is a synchronization problem: two pieces of state that are supposed to agree are being updated independently and drifting apart. Hindsight surfaces this class of problem by tracking related outputs over time. If the topic strength heatmap and the Topics Mastered counter are both derived from the same underlying data, they should move together. When Hindsight observes that they systematically diverge after every quiz session — heatmap updates, counter lags — it flags the divergence as a signal that something in the derivation chain is broken.
This is the observability principle that informs Hindsight's approach to agent memory: related outputs should be traceable back to shared inputs, and divergence between them is a diagnostic signal. Applied to our context, the outputs are UI values (heatmap cells, mastery counter, streak) and the shared input is the quiz result. When the outputs disagree, the tracing layer tells us where in the processing chain they separated.
Without that cross-session observability, we were looking at the app one render at a time. Everything looked correct in any given render. The bug only became visible when you watched the mastery counter across multiple quiz sessions and noticed it was always one behind — which requires the kind of longitudinal observation Hindsight is designed to provide.
What the Context Should Look Like
The fix for the stale closure is to remove topicsMastered as stored state and compute it from topicStrengths directly:
ts
// Remove: const [topicsMastered, setTopicsMastered] = useState(8);
// Add: derive it
const topicsMastered = topicStrengths.filter((t) => t.strength >= 80).length;
One line. No synchronization problem. The value is always consistent with topicStrengths because it is computed from it on every render. Same for weakTopicsCount, which is already derived correctly in the existing code:
const weakTopicsCount = topicStrengths.filter((t) => t.strength < 50).length;
That is the pattern topicsMastered should follow. The fact that one is derived and the other is stored state is inconsistent — and inconsistency in a data model is where bugs hide.
Beyond the derived state fix, the context would benefit from being split into domain-specific slices:
ts
// QuizContext: quizHistory, totalQuizzesTaken, totalCorrect, totalQuestions, weeklyScores
// StudyProgressContext: streak, topicStrengths, examDate
// MistakeContext: mistakes, retryingMistakeId + actions
This is not a performance optimization at the current scale — it is a clarity optimization. A component that only needs mistake data should not re-render when the streak changes. A component that only needs topic strengths should not have access to retry state. Narrow context consumers make dependencies explicit and make stale closure bugs harder to introduce, because the callback's dependency array is smaller and easier to audit.
The Broader Pattern
The StudyContext problem is a version of a pattern that appears in every growing codebase: a single shared store that starts as the right call and gradually accumulates state until it becomes the wrong call. At the point we built this, one context for all study state was appropriate. The app was small, the interactions were straightforward, and the overhead of multiple providers was not justified.
The signal that it is time to split is not size — it is when you find yourself writing useCallback with a dependency array that includes a piece of state you are about to update inside the callback. That is the shape of the stale closure problem, and it appears when a single context is managing state that has different update frequencies and different consumers.
In our case: retryingMistakeId changes on every retry interaction. topicStrengths changes on every quiz. streak changes once per day. examDate never changes after initialization. Bundling these together means every update to any of them is visible to every consumer — which is both a re-render problem and a cognitive overhead problem when reasoning about dependencies.
Lessons
Derived state should not be stored state. If a value can be computed from other state, compute it. The moment you store a derived value separately, you have created a synchronization obligation that will eventually be violated. topicsMastered and weakTopicsCount should be the same kind of value — both derived from topicStrengths on every render.
useCallback dependency arrays are a smell detector. If a callback's dependency array includes a piece of state that the callback itself updates, read that as a warning that two things that should be in the same update are in different places. The stale closure bug in addQuizResult was signaled by [topicStrengths] in the dependency array of a function that calls setTopicStrengths.
Monolithic context makes implicit dependencies hard to see. When everything is in one context, the relationship between topicStrengths and topicsMastered is not structurally visible — they are just two fields in a flat object. Splitting into domain contexts forces you to decide which context owns each field, which makes derived relationships explicit at the architecture level.
Cross-session observation catches what single-render testing misses. The mastery counter bug passed every manual test because in any single render it looked correct. Only across sessions did the one-quiz lag become visible. This is why Hindsight's longitudinal tracing is the right tool for this class of bug — not a better unit test.
Top comments (0)