Skip to main content

From Monolith to Composition: Simplifying AppContext

Tree branches with connected roots
Mar 18, 20263 min readReact, TypeScript, Architecture, Next.js

From Monolith to Composition — Simplifying AppContext

Overview

Project: State architecture refactor for danieljoffe.com

Role: Solo Developer

Duration: March 3–18, 2026 (4 phases over 15 days)

Purpose: Break a monolithic context provider into focused, composable units, eliminating unnecessary re-renders and keeping the provider tree maintainable as the app grows.

The Challenge

The portfolio site's entire state lived in one GlobalProvider: theme mode, modal state, window dimensions, and dark-mode preference all crammed into a single context. Every state change, however small, re-rendered every consumer in the tree.

The monolith:

GlobalProvider.tsx (before)
// 153 lines — one context, 15 properties, all coupled
const value = useMemo(
  () => ({
    isModalOpen,
    toggleModal,
    modalContent,
    setModalContent,
    windowWidth,
    windowHeight,
    isMobile,
    isTablet,
    isDesktop,
    themeMode,
    isDarkMode,
    setThemeMode,
    toggleDarkMode,
  }),
  [
    /* 15 dependencies — any change re-renders everything */
  ]
);

The problems:

  • Tree-wide re-renders: toggling dark mode re-rendered the modal consumers, opening a modal re-rendered the theme consumers, and resizing the window re-rendered everything.
  • useWindowResize fired on every frame: a resize listener updated state on every pixel of movement, so dragging a window edge thrashed the layout.
  • Untestable: you couldn't test theme behavior without also wiring up modal and window state.
  • Scaling bottleneck: adding a new provider (Toast notifications, say) meant bolting more properties onto an already bloated context.

My Approach

Phase 1: Split the monolith (March 5)

The first step was pulling the concerns apart. GlobalProvider became three focused providers, each owning exactly one piece of state:

ThemeProvider.tsx
// Owns: themeMode, isDarkMode, setThemeMode, toggleDarkMode
// Listens to: localStorage, system preference via matchMedia
export function ThemeProvider({ children }: WithChildren) {
  const [themeMode, setThemeMode] = useState<ThemeMode>('system');
  // ...only theme-related state and effects
}
ModalProvider.tsx
// Owns: isModalOpen, modalContent, toggleModal
// Uses matchMedia instead of useWindowResize
export default function ModalProvider({ children }: WithChildren) {
  const [isModalOpen, setIsModalOpen] = useState(false);
  // ...only modal-related state and effects
}

The win I cared about most was replacing useWindowResize with matchMedia. Instead of firing on every pixel of a resize, the modal provider now listens only for breakpoint crossings:

ModalProvider.tsx — matchMedia vs resize listener
// BEFORE: fires on every frame during resize
const { isMobile } = useWindowResize();
 
// AFTER: fires only when crossing the breakpoint
useEffect(() => {
  const mq = window.matchMedia('(max-width: 768px)');
  const handler = (e: MediaQueryListEvent) => {
    if (!e.matches && isModalOpen) setIsModalOpen(false);
  };
  mq.addEventListener('change', handler);
  return () => mq.removeEventListener('change', handler);
}, [isModalOpen]);

Phase 2: Extract to shared-ui (March 18)

ThemeProvider and ToastProvider moved into @danieljoffe/shared-ui, the monorepo's framework-agnostic component library, so any app in the monorepo gets consistent theming and toasts without reinventing them.

ModalProvider stayed in the app; it leans on Next.js dynamic imports and app-specific routing, which makes it a poor fit for a shared library.

Phase 3: Compose providers functionally (March 18)

The last piece was getting rid of the nested JSX. A composeProviders utility flattened the provider tree from a stack of nested callbacks into a plain list:

AppContext.tsx (after)
const composeProviders = (providers: ComponentType<WithChildren>[]) =>
  providers.reduce((Acc, Curr) => {
    const Composed = ({ children }: WithChildren) => (
      <Acc>
        <Curr>{children}</Curr>
      </Acc>
    );
    Composed.displayName =
      `${Acc.displayName ?? Acc.name}(${Curr.displayName ?? Curr.name})`;
    return Composed;
  });
 
const Providers = composeProviders([
  ThemeProvider,
  ToastProvider,
  ModalProvider,
]);
 
export default function AppContext({ children }: WithChildren) {
  return (
    <Providers>
      <Nav />
      <ErrorBoundary>{children}</ErrorBoundary>
      <Modal />
      <ScrollToTop />
      <KeyboardShortcuts />
      <Suspense fallback={null}>
        <ScrollToElement />
      </Suspense>
    </Providers>
  );
}

Adding or removing a provider is now a one-line change to an array, with no JSX nesting to untangle. The auto-generated displayName means React DevTools shows the full provider chain when you're debugging.

The Results

MetricBeforeAfter
Provider count1 monolithic (153 lines)3 focused (avg 91 lines each)
Context properties15 in one context3–5 per context
Resize listenerEvery frame (useWindowResize)Breakpoint only (matchMedia)
Re-render scopeEntire tree on any changeOnly affected consumers
Adding a providerAdd properties to monolith + update memo depsOne line in the providers array
Shared across monorepoNoTheme + Toast in shared-ui

Key Takeaways

  • Split by concern, not by size. The point wasn't smaller files; it was making sure a theme change can't re-render modal consumers, and vice versa. In React, separation of concerns maps straight onto render performance.
  • matchMedia over resize listeners. If all you care about is breakpoints, there's no reason to fire a state update on every pixel of a resize. matchMedia is the browser's own debouncer, sitting right there.
  • Functional composition scales. composeProviders is 8 lines of code, and it wiped out a whole class of nesting problems. The pattern keeps the provider tree honest: you can see exactly what wraps what, in order.
  • The shared-ui boundary matters. ThemeProvider and ToastProvider are framework-agnostic (React and Tailwind only), so they moved to the shared library; ModalProvider depends on Next.js, so it stayed in the app. Knowing where that line sits is what keeps the two from bleeding into each other.

Technologies Used

React Context API, TypeScript, matchMedia API, Next.js App Router, Nx Monorepo, @danieljoffe/shared-ui