If you've had the chance to install the brand new Laravel react and inertia.js starter kit, you know how amazing it is to have such a nice starting point for your new applications. The team behind it did an incredible job, providing the community with a solid project.
One thing that in today's web, for me at least, is table stakes is the option to have light and dark modes out of the box. The current starters, all the starters, have the option for a logged in user to head over to the settings > appearance and select what they prefer. You have three choices: light mode, dark mode, or to adhere to the system setting, which would pick the light/dark mode based on your user's operating system.
The one missing thing was having this option for the users who are not logged in and registered for your application to do the same thing! Luckily, the react starter, which I will be focusing on today, was built in a way that makes this refactor really nice and easy!
Current Implementation: The Custom Hook Approach
In the existing starter kit, theme management is handled by a custom hook called useAppearance
. This hook works great for logged-in users who can access the settings page, but it's not available globally for guests.
Here's a simplified version of how the current implementation works:
// Current implementation using a custom hookfunction useAppearance() { const [appearance, setAppearance] = useState('system'); // Helper functions to manage the theme... // Update function const updateAppearance = (mode) => { setAppearance(mode); localStorage.setItem('appearance', mode); // Apply theme changes... }; return { appearance, updateAppearance };} // In the Settings componentfunction AppearanceSettings() { const { appearance, updateAppearance } = useAppearance(); return ( <div> {/* Theme settings UI */} </div> );}
This works well when we only need theme controls in one place but presents challenges when we want to:
- Share theme state across multiple components
- Allow non-logged-in users to control theme preferences
- Maintain consistency throughout the application
The Core Problem: Prop Drilling
In React applications, data typically flows from parent components to children through props (properties). This works well for simple component trees but quickly becomes unwieldy as applications become complex. This is not a problem that is mainly a React issue, and you will hit this type of problem in Vue and Svelte as well.
What's an example of this? Imagine you have a deeply nested component structure like this:
App└── Header └── Navigation └── UserMenu └── DarkModeToggle
If your theme settings are managed in the App
component but needed in the DarkModeToggle
component, you would traditionally need to pass this data through each intermediate component:
<App theme={theme} setTheme={setTheme}> <Header theme={theme} setTheme={setTheme}> <Navigation theme={theme} setTheme={setTheme}> <UserMenu theme={theme} setTheme={setTheme}> <DarkModeToggle theme={theme} setTheme={setTheme} /> </UserMenu> </Navigation> </Header></App>
This pattern of passing props through components that don't actually use them is called "prop drilling" and creates several problems:
- It clutters components with irrelevant props, which can confuse other developers on your team
- It creates tight coupling between components, which makes it hard to reuse UI components elsewhere
- It makes refactoring difficult because you have to maintain data for other components that are not within the realm of the component you need to refactor
- It leads to more verbose, ugly code that can be akin to the incorrect or improper use of short arrow functions in PHP, which is a huge NO NO.
The Solution: Context/Provider Pattern
The Context/Provider pattern solves this by creating a "tunnel" that allows data to flow directly from a provider component to any consumer component in the tree, regardless of how deeply nested it is.
How It Works
- Create a Context: First, you create a context object using React's
createContext()
function. This context establishes a realm of shared data. - Set Up a Provider: You wrap a section of your component tree with a Provider component. The Provider defines what data is available to all components within its scope.
- Consume the Context: Any component within the Provider's tree can access the shared data directly without props.
By moving from a custom hook to a context provider, we'll be able to:
- Share theme state globally across the application
- Allow any component to access or update the theme
- Maintain a single source of truth for theme preferences
- Enable theme control for both guests and logged-in users
Implementation Steps
Let's break down our implementation into manageable pieces:
1. Define Types and Interfaces
First, let's define our data structure:
export type Appearance = 'light' | 'dark' | 'system'; interface ThemeContextType { appearance: Appearance; updateAppearance: (mode: Appearance) => void;}
This gives us a clear contract for what our theme context will provide.
2. Core Theme Helper Functions
Before building the provider, let's understand the helper functions we'll need:
// Check if system prefers dark modeconst prefersDark = () => { if (typeof window === 'undefined') { return false; } return window.matchMedia('(prefers-color-scheme: dark)').matches;}; // Store preference in cookies for server-side renderingconst setCookie = (name: string, value: string, days = 365) => { if (typeof document === 'undefined') { return; } const maxAge = days * 24 * 60 * 60; document.cookie = `${name}=${value};path=/;max-age=${maxAge};SameSite=Lax`;}; // Apply theme by toggling class on HTML elementconst applyTheme = (appearance: Appearance) => { const isDark = appearance === 'dark' || (appearance === 'system' && prefersDark()); document.documentElement.classList.toggle('dark', isDark);}; // Access system theme preference media queryconst mediaQuery = () => { if (typeof window === 'undefined') { return null; } return window.matchMedia('(prefers-color-scheme: dark)');}; // Handle system theme changesconst handleSystemThemeChange = () => { const currentAppearance = localStorage.getItem('appearance') as Appearance; applyTheme(currentAppearance || 'system');}; // Initialize theme on page loadexport function initializeTheme() { const savedAppearance = (localStorage.getItem('appearance') as Appearance) || 'system'; applyTheme(savedAppearance); mediaQuery()?.addEventListener('change', handleSystemThemeChange);}
Each function has a specific purpose in our theme management system.
3. Building the Context Provider
Now, let's create our context and provider:
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react'; const ThemeContext = createContext<ThemeContextType | undefined>(undefined); export function ThemeProvider({ children }: { children: ReactNode }) { const [appearance, setAppearance] = useState<Appearance>('system'); const updateAppearance = useCallback((mode: Appearance) => { setAppearance(mode); localStorage.setItem('appearance', mode); setCookie('appearance', mode); applyTheme(mode); }, []); useEffect(() => { // Initialize from localStorage on mount const savedAppearance = localStorage.getItem('appearance') as Appearance | null; updateAppearance(savedAppearance || 'system'); // Clean up event listener on unmount return () => mediaQuery()?.removeEventListener('change', handleSystemThemeChange); }, [updateAppearance]); return ( <ThemeContext.Provider value={{ appearance, updateAppearance }}> {children} </ThemeContext.Provider> );}
The provider wraps the state management for our theme and provides it to all the children's components.
4. Custom Hook for Consuming the Context
We'll create a simple hook to make it easy for components to use our context:
export function useTheme() { const context = useContext(ThemeContext); if (context === undefined) { throw new Error('useTheme must be used within a ThemeProvider'); } return context;}
This gives us a clean API for accessing theme data anywhere in our app.
Creating Consumer Components
Now that we have our provider set up let's create a component that will allow users to toggle themes from anywhere in the app:
// components/ThemeToggleFloat.tsximport { useTheme, type Appearance } from '@/lib/theme-provider';import { Monitor, Moon, Sun } from 'lucide-react';import { cn } from '@/lib/utils';import { useState } from 'react'; export function ThemeToggleFloat() { const { appearance, updateAppearance } = useTheme(); const [isExpanded, setIsExpanded] = useState(false); const toggleExpanded = () => setIsExpanded(!isExpanded); const options: { value: Appearance; icon: React.FC<React.SVGProps<SVGSVGElement>>; label: string }[] = [ { value: 'light', icon: Sun, label: 'Light' }, { value: 'dark', icon: Moon, label: 'Dark' }, { value: 'system', icon: Monitor, label: 'System' }, ]; return ( <div className="fixed bottom-4 right-4 z-50"> <div className="relative"> <button onClick={toggleExpanded} className="flex h-12 w-12 items-center justify-center rounded-full bg-white shadow-lg transition-all hover:bg-gray-100 dark:bg-neutral-800 dark:hover:bg-neutral-700" aria-label="Toggle theme selector" > {appearance === 'light' && <Sun className="h-5 w-5" />} {appearance === 'dark' && <Moon className="h-5 w-5" />} {appearance === 'system' && <Monitor className="h-5 w-5" />} </button> {isExpanded && ( <div className="absolute bottom-16 right-0 rounded-lg bg-white p-2 shadow-xl dark:bg-neutral-800" > <div className="flex flex-col gap-1"> {options.map(({ value, icon: Icon, label }) => ( <button key={value} onClick={() => { updateAppearance(value); setIsExpanded(false); }} className={cn( 'flex w-32 items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors', appearance === value ? 'bg-neutral-100 font-medium dark:bg-neutral-700' : 'text-neutral-600 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700' )} > <Icon className="h-4 w-4" /> {label} </button> ))} </div> </div> )} </div> </div> );}
This component creates a floating button that expands to show theme options. When a user selects an option, it calls updateAppearance(value)
with one of our three possible values: light
, dark
, or system
.
Integration with the Application
The final step is to integrate our theme provider into the application. We'll wrap our entire app with the provider so that every component has access to the theme context:
// app.tsximport '../css/app.css'; import { createInertiaApp } from '@inertiajs/react';import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';import { createRoot } from 'react-dom/client';import { ThemeProvider, initializeTheme } from './lib/theme-provider';import { FloatingThemeProvider } from './components/floating-theme-provider'; const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; createInertiaApp({ title: (title) => `${title} - ${appName}`, resolve: (name) => resolvePageComponent(`./pages/${name}.tsx`, import.meta.glob('./pages/**/*.tsx')), setup({ el, App, props }) { const root = createRoot(el); root.render( <ThemeProvider> <FloatingThemeProvider> <App {...props} /> </FloatingThemeProvider> </ThemeProvider> ); }, progress: { color: '#4B5563', },}); // This will set light / dark mode on loadinitializeTheme();
We also created a simple wrapper for our floating toggle button:
// components/floating-theme-provider.tsximport { ThemeToggleFloat } from '@/components/theme-toggle-float';import { ReactNode } from 'react'; export function FloatingThemeProvider({ children }: { children: ReactNode }) { return ( <> {children} <ThemeToggleFloat /> </> );}
This adds our floating theme toggle to every page while maintaining the original App structure.
Benefits of This Approach
By refactoring to the Context/Provider pattern, we've achieved several benefits:
- Global Accessibility: Any component can access and update theme preferences
- Single Source of Truth: All theme logic is centralized in one provider
- Clean API: Components use a simple
useTheme()
hook without prop drilling - Consistency: Theme preferences persist across all pages and components
- Enhanced User Experience: Both guests and logged-in users can customize their experience
Conclusion
The Context/Provider pattern gives us a powerful way to manage global state in React applications. By refactoring our theme management from a custom hook to a context provider, we've made theme preferences accessible throughout the application while maintaining a clean, maintainable codebase.
If you now run npm run build
, you will see that nothing has changed in your app's functionality, but we've greatly improved its architecture and added a floating theme toggle button that works for all users!
If you want to see all the changes made to the starter implementing the code in this walkthrough, you can check it out here. I made a pull request against the current react/inertia starter kit main
branch. I think this helps to see the full list of changes.
As always, I would love to hear what you think!

Yaz is a full stack developer with a passion for everything Laravel, React, TypeScript Tailwind CSS and Inertia.js.