Sharing State Across Components in React & Next.js: A Complete Guide
- Published on
- 8 min read
- Authors

- Name
- Robin te Hofstee
- @Robin_teHofstee
Sharing State Across Components in React & Next.js: A Complete Guide
Introduction
Have you ever struggled with passing props through multiple nested components, or found yourself duplicating state across different parts of your application? In this tutorial, I'll show you how to use React Context API to share state globally across your entire Next.js application.
We'll build a real-world example: a synchronized audio player that can be controlled from multiple components on the same page.
What is React Context?
React Context is a built-in way to share data between components without having to pass props through every level of the component tree. Think of it as a "global state" that any component can access.
When to Use Context
✅ Use Context for:
- Theme settings (dark mode/light mode)
- User authentication state
- Language/localization
- Media players that need controls in multiple places
- Shopping cart state
❌ Don't Use Context for:
- Highly frequent updates (causes re-renders)
- Simple parent-child communication (use props)
- Data that only 1-2 components need
Real-World Example: Building a Shared Audio Player
Let's build an audio player that can be controlled from multiple locations:
- A floating play button (always visible)
- A sticky player bar (appears when scrolling)
- Both control the SAME audio stream
Step 1: Create the Context
First, create AudioContext.tsx in your components folder:
'use client'
import React, { createContext, useContext, useState, useRef, ReactNode } from 'react';
// Define the shape of our context data
interface AudioContextType {
audioRef: React.RefObject<HTMLAudioElement>;
isPlaying: boolean;
setIsPlaying: (playing: boolean) => void;
handlePlayPause: () => void;
audioSrc: string;
}
// Create the context with undefined as default
const AudioContext = createContext<AudioContextType | undefined>(undefined);
// Provider component that wraps your app
export function AudioProvider({ children }: { children: ReactNode }) {
const audioRef = useRef<HTMLAudioElement>(null);
const [audioSrc] = useState("https://example.com/stream.mp3");
const [isPlaying, setIsPlaying] = useState(false);
const handlePlayPause = () => {
if (isPlaying) {
if (audioRef.current) {
audioRef.current.pause();
setIsPlaying(false);
}
} else {
if (audioRef.current) {
audioRef.current.play();
setIsPlaying(true);
}
}
};
return (
<AudioContext.Provider
value={{ audioRef, isPlaying, setIsPlaying, handlePlayPause, audioSrc }}
>
{/* Single audio element for the entire app */}
<audio preload="none" ref={audioRef}>
<source src={audioSrc} type="audio/mpeg" />
</audio>
{children}
</AudioContext.Provider>
);
}
// Custom hook to use the audio context
export function useAudio() {
const context = useContext(AudioContext);
if (context === undefined) {
throw new Error('useAudio must be used within an AudioProvider');
}
return context;
}
Step 2: Wrap Your App with the Provider
In your app/layout.tsx (Next.js App Router):
import { AudioProvider } from "@/components/AudioContext";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<AudioProvider>
{children}
</AudioProvider>
</body>
</html>
);
}
Step 3: Create Components That Use the Shared State
Floating Player Button
'use client'
import { Play, Pause } from 'lucide-react';
import { useAudio } from './AudioContext';
export default function FloatingPlayer() {
const { isPlaying, handlePlayPause } = useAudio();
return (
<button
onClick={handlePlayPause}
className="fixed bottom-6 right-6 z-50 w-16 h-16
bg-blue-500 hover:bg-blue-600
text-white rounded-full shadow-2xl
flex items-center justify-center
transition-all transform hover:scale-110"
>
{isPlaying ? (
<Pause className="w-8 h-8" />
) : (
<Play className="w-8 h-8 ml-1" />
)}
</button>
);
}
Sticky Player Bar
'use client'
import { Play, Pause } from 'lucide-react';
import { useAudio } from './AudioContext';
export default function PlayerBar() {
const { isPlaying, handlePlayPause } = useAudio();
return (
<div className="sticky top-0 z-40 bg-gray-900 border-b border-gray-800">
<div className="max-w-7xl mx-auto px-6 py-3 flex items-center gap-4">
<button
onClick={handlePlayPause}
className="w-10 h-10 bg-blue-500 rounded-full
flex items-center justify-center"
>
{isPlaying ? (
<Pause className="w-5 h-5" />
) : (
<Play className="w-5 h-5 ml-0.5" />
)}
</button>
<span className="text-white">
{isPlaying ? 'Now Playing' : 'Paused'}
</span>
</div>
</div>
);
}
Step 4: Add Components to Your Page
import FloatingPlayer from "@/components/FloatingPlayer";
import PlayerBar from "@/components/PlayerBar";
export default function Home() {
return (
<main>
<PlayerBar />
<div className="p-8">
<h1>My Audio App</h1>
<p>Click either play button - they're synchronized!</p>
</div>
<FloatingPlayer />
</main>
);
}
How It Works
- AudioProvider wraps your entire app in
layout.tsx - The provider holds the single source of truth for audio state
- Any component can access this state using the
useAudio()hook - When one component updates the state, all components re-render with the new state
- Both buttons control the same audio element (no duplicates!)
Key Concepts Explained
1. Context Provider Pattern
<AudioContext.Provider value={{ /* shared data */ }}>
{children}
</AudioContext.Provider>
The Provider wraps components and makes data available to them.
2. Custom Hook Pattern
export function useAudio() {
const context = useContext(AudioContext);
if (context === undefined) {
throw new Error('useAudio must be used within an AudioProvider');
}
return context;
}
This custom hook:
- Simplifies accessing the context
- Provides type safety
- Throws helpful errors if used incorrectly
3. TypeScript Interface
interface AudioContextType {
audioRef: React.RefObject<HTMLAudioElement>;
isPlaying: boolean;
setIsPlaying: (playing: boolean) => void;
handlePlayPause: () => void;
audioSrc: string;
}
Defines exactly what data/functions are available in the context.
Advanced: Adding More Features
Track Info State
Add current song information to the context:
const [trackInfo, setTrackInfo] = useState({
title: 'Unknown',
artist: 'Unknown',
cover: '/default.jpg'
});
// In your context value:
value={{
audioRef,
isPlaying,
handlePlayPause,
trackInfo,
setTrackInfo
}}
Volume Control
const [volume, setVolume] = useState(1);
const handleVolumeChange = (newVolume: number) => {
setVolume(newVolume);
if (audioRef.current) {
audioRef.current.volume = newVolume;
}
};
Performance Considerations
Problem: Unnecessary Re-renders
Every component using useAudio() will re-render when any part of the context changes.
Solution: Split Contexts
For large apps, split into multiple contexts:
// AudioStateContext.tsx - For state that changes often
export const AudioStateContext = createContext<AudioState | undefined>(undefined);
// AudioActionsContext.tsx - For functions that rarely change
export const AudioActionsContext = createContext<AudioActions | undefined>(undefined);
Using useMemo
Memoize context values to prevent unnecessary re-renders:
const contextValue = useMemo(
() => ({
audioRef,
isPlaying,
handlePlayPause,
audioSrc
}),
[isPlaying, audioSrc] // Only recreate when these change
);
return (
<AudioContext.Provider value={contextValue}>
{children}
</AudioContext.Provider>
);
Common Pitfalls & Solutions
❌ Pitfall 1: Using Context Outside Provider
// This will throw an error!
function MyComponent() {
const { isPlaying } = useAudio(); // Error if not wrapped
return <div>{isPlaying ? 'Playing' : 'Paused'}</div>;
}
✅ Solution: Always wrap components with the provider.
❌ Pitfall 2: Creating Multiple Audio Elements
// Bad - Each component creates its own audio element
function PlayerButton() {
const audioRef = useRef<HTMLAudioElement>(null); // ❌
return <audio ref={audioRef}>...</audio>;
}
✅ Solution: Create the audio element once in the provider.
❌ Pitfall 3: Forgetting 'use client' Directive
// This will cause errors in Next.js App Router
import { useAudio } from './AudioContext';
function MyComponent() {
const { isPlaying } = useAudio(); // Error!
// ...
}
✅ Solution: Add 'use client' at the top of files using hooks.
Testing Your Context
import { render, screen } from '@testing-library/react';
import { AudioProvider } from './AudioContext';
import FloatingPlayer from './FloatingPlayer';
test('floating player shows play button when not playing', () => {
render(
<AudioProvider>
<FloatingPlayer />
</AudioProvider>
);
expect(screen.getByLabelText('Play')).toBeInTheDocument();
});
Alternative: Zustand for Complex State
For more complex state management, consider Zustand:
import { create } from 'zustand';
export const useAudioStore = create((set) => ({
isPlaying: false,
volume: 1,
trackInfo: { title: 'Unknown', artist: 'Unknown' },
setIsPlaying: (playing) => set({ isPlaying: playing }),
setVolume: (volume) => set({ volume }),
setTrackInfo: (info) => set({ trackInfo: info }),
}));
// Usage in components:
const isPlaying = useAudioStore((state) => state.isPlaying);
const setIsPlaying = useAudioStore((state) => state.setIsPlaying);
Conclusion
React Context API is a powerful tool for sharing state across your application. Key takeaways:
✅ Use Context for global state that many components need
✅ Create custom hooks for easier access
✅ Wrap your app in the Provider
✅ Consider performance with useMemo for large apps
✅ Split contexts if you have many different pieces of state
The pattern we built (shared audio player) demonstrates real-world Context usage. You can apply this same pattern to:
- Authentication state
- Theme preferences
- Shopping cart
- Notification systems
- Form data across wizard steps
Resources
Questions? Drop a comment below or reach out on Twitter/X!
Found this helpful? Share it with fellow developers learning React and Next.js!