Skip to the code
cf.

Theme context

The tea shop's site has a light and a dark theme. The button that switches it sits in the header, and the cards that change colour sit deep inside the page. Passing the theme down through every layer as a prop gets old fast, so put it in context.

Export ThemeProvider by name. It takes children and an optional initialTheme, which is "light" or "dark" and is "light" when it isn't given. It keeps the current theme and makes it available to every component inside it, however deep.

Export useTheme() by name. Inside a ThemeProvider it returns { theme, toggleTheme }: theme is "light" or "dark", and toggleTheme() switches to the other one. Every component that calls useTheme() under the same provider sees the same theme, and they all update together. Two separate providers each keep their own theme.

Called outside any ThemeProvider, useTheme() throws an Error whose message is exactly useTheme must be used inside a ThemeProvider, so a missing provider is easy to spot instead of failing later with something like Cannot read properties of null.

function ThemeLabel() {
  const { theme } = useTheme();
  return <p>Theme: {theme}</p>;
}
 
function ThemeButton() {
  const { toggleTheme } = useTheme();
  return <button onClick={toggleTheme}>Switch theme</button>;
}
 
<ThemeProvider initialTheme="dark">
  <header><ThemeButton /></header>
  <main><section><ThemeLabel /></section></main>
</ThemeProvider>

The default export, App, is a small page for the Preview that uses both. The tests use their own components like the ones above.

What the tests check

  1. starts with the light theme
    function ThemeLabel() {
      const { theme } = useTheme();
      return <p>Theme: {theme}</p>;
    }
    render(
      <ThemeProvider>
        <ThemeLabel />
      </ThemeProvider>,
    );
    expect(screen.getByText("Theme: light")).toBeInTheDocument();
    
  2. starts with initialTheme when it's given
    function ThemeLabel() {
      const { theme } = useTheme();
      return <p>Theme: {theme}</p>;
    }
    render(
      <ThemeProvider initialTheme="dark">
        <ThemeLabel />
      </ThemeProvider>,
    );
    expect(screen.getByText("Theme: dark")).toBeInTheDocument();
    
  3. toggleTheme switches between light and dark
    function ThemeButton() {
      const { theme, toggleTheme } = useTheme();
      return <button onClick={toggleTheme}>Theme: {theme}</button>;
    }
    render(
      <ThemeProvider>
        <ThemeButton />
      </ThemeProvider>,
    );
    const button = screen.getByRole("button");
    await user.click(button);
    expect(button).toHaveTextContent("Theme: dark");
    await user.click(button);
    expect(button).toHaveTextContent("Theme: light");
    
  4. a component deep in another branch sees the change
    function ThemeButton() {
      const { toggleTheme } = useTheme();
      return <button onClick={toggleTheme}>Switch theme</button>;
    }
    function ThemeLabel() {
      const { theme } = useTheme();
      return <p>Theme: {theme}</p>;
    }
    render(
      <ThemeProvider>
        <header>
          <nav>
            <ThemeButton />
          </nav>
        </header>
        <main>
          <section>
            <article>
              <ThemeLabel />
            </article>
          </section>
        </main>
      </ThemeProvider>,
    );
    await user.click(screen.getByRole("button", { name: "Switch theme" }));
    expect(screen.getByText("Theme: dark")).toBeInTheDocument();
    
  5. every consumer under one provider shows the same theme
    function ThemeButton() {
      const { toggleTheme } = useTheme();
      return <button onClick={toggleTheme}>Switch theme</button>;
    }
    function ThemeLabel({ where }) {
      const { theme } = useTheme();
      return <p>{where}: {theme}</p>;
    }
    render(
      <ThemeProvider>
        <ThemeLabel where="Header" />
        <ThemeButton />
        <div>
          <ThemeLabel where="Card" />
        </div>
        <ThemeLabel where="Footer" />
      </ThemeProvider>,
    );
    await user.click(screen.getByRole("button", { name: "Switch theme" }));
    expect(screen.getByText("Header: dark")).toBeInTheDocument();
    expect(screen.getByText("Card: dark")).toBeInTheDocument();
    expect(screen.getByText("Footer: dark")).toBeInTheDocument();
    
  6. two providers keep their own themes
    function ThemeButton({ label }) {
      const { theme, toggleTheme } = useTheme();
      return <button onClick={toggleTheme}>{label}: {theme}</button>;
    }
    render(
      <div>
        <ThemeProvider>
          <ThemeButton label="Shop" />
        </ThemeProvider>
        <ThemeProvider>
          <ThemeButton label="Blog" />
        </ThemeProvider>
      </div>,
    );
    await user.click(screen.getByRole("button", { name: "Shop: light" }));
    expect(screen.getByRole("button", { name: "Shop: dark" })).toBeInTheDocument();
    expect(screen.getByRole("button", { name: "Blog: light" })).toBeInTheDocument();
    
  7. a component that appears later reads the current theme
    function Controls() {
      const { toggleTheme } = useTheme();
      const [showCard, setShowCard] = React.useState(false);
      return (
        <div>
          <button onClick={toggleTheme}>Switch theme</button>
          <button onClick={() => setShowCard(true)}>Show card</button>
          {showCard && <ThemeLabel />}
        </div>
      );
    }
    function ThemeLabel() {
      const { theme } = useTheme();
      return <p>Theme: {theme}</p>;
    }
    render(
      <ThemeProvider>
        <Controls />
      </ThemeProvider>,
    );
    await user.click(screen.getByRole("button", { name: "Switch theme" }));
    await user.click(screen.getByRole("button", { name: "Show card" }));
    expect(screen.getByText("Theme: dark")).toBeInTheDocument();
    
  8. useTheme outside a ThemeProvider throws a helpful error
    function Lost() {
      const { theme } = useTheme();
      return <p>Theme: {theme}</p>;
    }
    expect(() => render(<Lost />)).toThrow("useTheme must be used inside a ThemeProvider");
    

Learn more

App.jsxSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).