Why Hindsight Made Us Rethink Our Global Study Context

Published: (March 23, 2026 at 05:10 PM EDT)
5 min read
Source: Dev.to

Source: Dev.to

The Problem with a Monolithic Context

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 mis‑counted mastered topics after every session.
  • Made it structurally impossible to connect related pieces of state without introducing stale‑closure bugs.

The context itself wasn’t wrong; it was 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. Below is the full shape of what it manages:

interface StudyState {
  // Data
  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
  • 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.

The Bug That the Monolithic 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, so setTopicStrengths has not flushed when setTopicsMastered runs. Consequently, 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 lags by one quiz.

What Hindsight Shows About Context Design

What the Context Should Look Like

// Remove stored state
// const [topicsMastered, setTopicsMastered] = useState(8);

// Derive it instead
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 on every render.

The same pattern already exists for weakTopicsCount:

const weakTopicsCount = topicStrengths.filter((t) => t.strength < 50).length;

topicsMastered should follow that pattern. Storing a derived value separately creates an inconsistency that hides bugs.

Suggested Context Split

DomainHolds
QuizContextquizHistory, totalQuizzesTaken, totalCorrect, totalQuestions, weeklyScores
StudyProgressContextstreak, topicStrengths, examDate
MistakeContextmistakes, retryingMistakeId + actions (markMistakeReviewed, retryMistake, clearRetry)

This isn’t a performance optimization at the current scale—it’s a clarity optimization. A component that only needs mistake data should not re‑render when the streak changes, and a component that only needs topic strengths should not have access to retry state. Narrower 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 & Lessons

  1. Derived state should not be stored state.
    If a value can be computed from other state, compute it. Storing a derived value creates a synchronization obligation that will eventually be violated. topicsMastered and weakTopicsCount should both be derived from topicStrengths on every render.

  2. useCallback dependency arrays are a smell detector.
    If a callback’s dependency array includes a piece of state that the callback itself updates, treat it as a warning that two things that should be updated together are split apart. The stale‑closure bug in addQuizResult was signaled by [topicStrengths] in the dependency array of a function that also calls setTopicStrengths.

  3. Monolithic context hides implicit dependencies.
    When everything lives in one context, the relationship between topicStrengths and topicsMastered is not structurally visible—they’re just two fields in a flat object. Splitting into domain‑specific contexts forces you to decide which context owns each field, making derived relationships explicit.

By deriving where possible, splitting contexts by domain, and keeping callbacks’ dependencies minimal, we eliminate stale‑closure bugs, reduce unnecessary re‑renders, and make the data flow in the app far easier to understand and maintain.

Cross‑Session Observation

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.

0 views
Back to Blog

Related posts

Read more »

Barbershop Web App

Summary A full‑featured, responsive barbershop website built with Next.js, React, TypeScript, Tailwind CSS, Firebase, and a collection of reusable UI component...