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:
// 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.
useWindowResizefired 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:
// 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
}// 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:
// 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:
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
| Metric | Before | After |
|---|---|---|
| Provider count | 1 monolithic (153 lines) | 3 focused (avg 91 lines each) |
| Context properties | 15 in one context | 3–5 per context |
| Resize listener | Every frame (useWindowResize) | Breakpoint only (matchMedia) |
| Re-render scope | Entire tree on any change | Only affected consumers |
| Adding a provider | Add properties to monolith + update memo deps | One line in the providers array |
| Shared across monorepo | No | Theme + 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.
matchMediaover resize listeners. If all you care about is breakpoints, there's no reason to fire a state update on every pixel of a resize.matchMediais the browser's own debouncer, sitting right there.- Functional composition scales.
composeProvidersis 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
