Welcome to my personal blog

Sharing State Across Components in React & Next.js: A Complete Guide

Published on
8 min read
← Back to the blog
Authors

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:

  1. A floating play button (always visible)
  2. A sticky player bar (appears when scrolling)
  3. 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

  1. AudioProvider wraps your entire app in layout.tsx
  2. The provider holds the single source of truth for audio state
  3. Any component can access this state using the useAudio() hook
  4. When one component updates the state, all components re-render with the new state
  5. 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!

Comments