Laravel Idea for PhpStorm - Full-featured IDE for productive artisans!

React Theme Provider: A Walkthrough

Published on by

React Theme Provider: A Walkthrough image

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 hook
function 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 component
function 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:

  1. Share theme state across multiple components
  2. Allow non-logged-in users to control theme preferences
  3. 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

  1. Create a Context: First, you create a context object using React's createContext() function. This context establishes a realm of shared data.
  2. 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.
  3. 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 mode
const prefersDark = () => {
if (typeof window === 'undefined') {
return false;
}
return window.matchMedia('(prefers-color-scheme: dark)').matches;
};
 
// Store preference in cookies for server-side rendering
const 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 element
const applyTheme = (appearance: Appearance) => {
const isDark = appearance === 'dark' || (appearance === 'system' && prefersDark());
document.documentElement.classList.toggle('dark', isDark);
};
 
// Access system theme preference media query
const mediaQuery = () => {
if (typeof window === 'undefined') {
return null;
}
return window.matchMedia('(prefers-color-scheme: dark)');
};
 
// Handle system theme changes
const handleSystemThemeChange = () => {
const currentAppearance = localStorage.getItem('appearance') as Appearance;
applyTheme(currentAppearance || 'system');
};
 
// Initialize theme on page load
export 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.tsx
import { 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.tsx
import '../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 load
initializeTheme();

We also created a simple wrapper for our floating toggle button:

// components/floating-theme-provider.tsx
import { 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:

  1. Global Accessibility: Any component can access and update theme preferences
  2. Single Source of Truth: All theme logic is centralized in one provider
  3. Clean API: Components use a simple useTheme() hook without prop drilling
  4. Consistency: Theme preferences persist across all pages and components
  5. 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 Jallad photo

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

Cube

Laravel Newsletter

Join 40k+ other developers and never miss out on new tips, tutorials, and more.

image
Laravel Cloud

Easily create and manage your servers and deploy your Laravel applications in seconds.

Visit Laravel Cloud
Curotec logo

Curotec

World class Laravel experts with GenAI dev skills. LATAM-based, embedded engineers that ship fast, communicate clearly, and elevate your product. No bloat, no BS.

Curotec
Bacancy logo

Bacancy

Supercharge your project with a seasoned Laravel developer with 4-6 years of experience for just $2500/month. Get 160 hours of dedicated expertise & a risk-free 15-day trial. Schedule a call now!

Bacancy
Laravel Forge logo

Laravel Forge

Easily create and manage your servers and deploy your Laravel applications in seconds.

Laravel Forge
Tinkerwell logo

Tinkerwell

The must-have code runner for Laravel developers. Tinker with AI, autocompletion and instant feedback on local and production environments.

Tinkerwell
Cut PHP Code Review Time & Bugs into Half with CodeRabbit logo

Cut PHP Code Review Time & Bugs into Half with CodeRabbit

CodeRabbit is an AI-powered code review tool that specializes in PHP and Laravel, running PHPStan and offering automated PR analysis, security checks, and custom review features while remaining free for open-source projects.

Cut PHP Code Review Time & Bugs into Half with CodeRabbit
Join the Mastering Laravel community logo

Join the Mastering Laravel community

Connect with experienced developers in a friendly, noise-free environment. Get insights, share ideas, and find support for your coding challenges. Join us today and elevate your Laravel skills!

Join the Mastering Laravel community
Kirschbaum logo

Kirschbaum

Providing innovation and stability to ensure your web application succeeds.

Kirschbaum
Shift logo

Shift

Running an old Laravel version? Instant, automated Laravel upgrades and code modernization to keep your applications fresh.

Shift
Lucky Media logo

Lucky Media

Get Lucky Now - the ideal choice for Laravel Development, with over a decade of experience!

Lucky Media
Lunar: Laravel E-Commerce logo

Lunar: Laravel E-Commerce

E-Commerce for Laravel. An open-source package that brings the power of modern headless e-commerce functionality to Laravel.

Lunar: Laravel E-Commerce
LaraJobs logo

LaraJobs

The official Laravel job board

LaraJobs
SaaSykit: Laravel SaaS Starter Kit logo

SaaSykit: Laravel SaaS Starter Kit

SaaSykit is a Multi-tenant Laravel SaaS Starter Kit that comes with all features required to run a modern SaaS. Payments, Beautiful Checkout, Admin Panel, User dashboard, Auth, Ready Components, Stats, Blog, Docs and more.

SaaSykit: Laravel SaaS Starter Kit
MongoDB logo

MongoDB

Enhance your PHP applications with the powerful integration of MongoDB and Laravel, empowering developers to build applications with ease and efficiency. Support transactional, search, analytics and mobile use cases while using the familiar Eloquent APIs. Discover how MongoDB's flexible, modern database can transform your Laravel applications.

MongoDB

The latest

View all →
Confidently Extract Single Array Items with Laravel's Arr::sole() Method image

Confidently Extract Single Array Items with Laravel's Arr::sole() Method

Read article
Safely Retry API calls in Laravel image

Safely Retry API calls in Laravel

Read article
Laravel's AsHtmlString Cast for Elegant HTML Attribute Management image

Laravel's AsHtmlString Cast for Elegant HTML Attribute Management

Read article
NativePHP for Mobile v1 — Launching May 2 image

NativePHP for Mobile v1 — Launching May 2

Read article
Map Eloquent Attributes into an Object Using the Collection Cast in Laravel 12.10 image

Map Eloquent Attributes into an Object Using the Collection Cast in Laravel 12.10

Read article
Converting Array Values to Enum Instances with Laravel's mapInto Method image

Converting Array Values to Enum Instances with Laravel's mapInto Method

Read article