setState through Context not setting state (NextJS)

Viewed 37

I'm taking my first pass at using NextJS, and also my first pass at using a headless CMS (DatoCMS in this case).

Everything was actually working fine, but I found myself using a lot of prop drilling, to get information returned from the CMS down into deeply nested components, and that was becoming cumbersome. After some googling, I decided to try to use React Context to avoid prop drilling as described here.

The first use case is getting site branding info and menu items down to the nav component (this info will almost always be the same, everywhere on the site, but there might be some sections (e.g., landing pages) where they're different).

The problem is, the React useState set function as passed through the ContextProvider doesn't actually seem to set the state. There are several questions on here about state not being updated in various scenarios, but this isn't setting the initial state after NextJS grabs the data from getStaticProps, but here it's not doing it even if I manually call the set function directly in the component wrapped in ContextProvider, never mind somewhere farther down the stack.

As described in that blog post, I have the context provider pulled out into its own component:

// context/header.js
import { createContext, useContext, useState } from 'react';

const HeaderContext = createContext();

export function HeaderProvider({children}) {
    const [header, setHeader] = useState(null);

    const value = { header, setHeader };

    return (
        <HeaderContext.Provider value={value}>
            {children}
        </HeaderContext.Provider>
    )
}

export function useHeader() {
    return useContext(HeaderContext);
}

Then, in _app.js, I wrap my <Header /> component in the context provider:

// pages/_app.js
import Header from '../components/Header';
import { HeaderProvider } from '../lib/context/header';

function MyApp({ Component, pageProps }) {
  return (
    <>
      <HeaderProvider>
        <Header { ...pageProps } /> 
      </HeaderProvider>
      <Component {...pageProps} />
    </>
  )
}

export default MyApp

I'm just starting this out, so right now, everything is designed to be a static page (no server side rendering or client side React yet).

The main page, from index.js, successfully grabs the data from DatoCMS:

// pages/index.js
import { request } from "../lib/datocms";
import { HOMEPAGE_QUERY } from '../lib/query';

export async function getStaticProps() {
  const data = await request({
    query: HOMEPAGE_QUERY
  });
  return {
    props: { data }
  };
}

export default function Home({ data }) {
  return (
    <div>
      This is the home page
    </div>
  );
}

So far, this is working, because in my <Header /> component, I can log the data passed.

As I understand how NextJS works, that getStaticProps call populates pageProps which get passed back to the Component (the page) to generate the static html. The results of the request call in getStaticProps is in fact making it's way back to pageProps to be used both in the <Component /> and the <Header /> in _app.js, but when I try to use the setHeader() function from header.js, pulled in by calling useHeader(), the value of the stateful header is always null.

// components/Header.js
import React, { useEffect } from 'react'
import { useHeader } from '../lib/context/header';
import Navbar from './Navbar';

export default function Header({data}) {
    const HeaderData = {
        siteName: data.siteBranding.siteName,
        companyLogo: data.siteBranding.companyLogo,
        menu: data?.menu
    };
    console.log(HeaderData);
    // outputs:
    // {
    //   siteName: 'My Site',
    //   companyLogo: {
    //     alt: 'My Site Logo',
    //     height: 113,
    //     url: 'https://www.datocms-assets.com/<url>',
    //     width: 122
    //   },
    //   menu: { title: 'primary', menuItems: [ [Object], [Object] ] }
    // }

    const { setHeader } = useHeader();

    useEffect(() => {
      setHeader(HeaderData)
    }, [HeaderData]);
    
    return (
        <header>
            <Navbar />
        </header>
    )
}

And then using the context in <Navbar />:

import React from 'react';
import { useHeader } from '../lib/context/header';

export default function Navbar() {
    const { header } = useHeader();
    console.log(header); // null :(

    return (
        <div>This is my nav</div>
    )
}

How do I actually get my context state into <Navbar />?

The problem is, the header context in <Navbar /> is always the initial state of null. There was a similar question, but it wasn't using NextJS, just React, and the answer there seemed to be that he was trying to use the useContext call outside of the component chain he had wrapped in ContextProvider. Maybe I'm missing something, but I'm pretty sure that's not my issue.

So, I did the natural debugging thing, and started adding a bunch of console.logs. Before I even get to the <Navbar /> component, it appears that the setHeader() call isn't actually updating the state.

Here's an updated <Header />:

export default function Header({data}) {
    const HeaderData = {
        siteName: data.siteBranding.siteName,
        companyLogo: data.siteBranding.companyLogo,
        menu: data?.menu
    };    
    console.log('Set HeaderData to');
    console.log(HeaderData);

    // In the initial code, I just pulled in setHeader here, but now pulling in header for debugging purposes
    const { header, setHeader } = useHeader();
    setHeader(HeaderData); // various incantations here
    console.log('Just set header, header is');
    console.log(header)

But no matter what incantation I use at "various incantations here", the result logged to console is always the same:

Set HeaderData to
{
  siteName: 'My Site',
  companyLogo: {
    alt: 'My Site Logo',
    height: 113,
    url: 'https://www.datocms-assets.com/<url>',
    width: 122
  },
  menu: { title: 'primary', menuItems: [ [Object], [Object] ] }
}
Just set header, header is
null

I've tried setHeader(HeaderData), which seems most analogous to what he did in that original blog post, but since that wasn't working, I also tried setHeader({HeaderData}) and setHeader({...HeaderData}), but the results are identical.

Why isn't this set setting?

2 Answers

There are a few issues with the code as written. Firstly you shouldn't try to console log the new state value after setting it. It won't work. Calling setHeader doesn't change the value of header in this call. It causes it to be changed for the next rendering of the component (which will happen immediately).

Secondly, don't use useEffect to synchronize your state.

    const HeaderData = {
        siteName: data.siteBranding.siteName,
        companyLogo: data.siteBranding.companyLogo,
        menu: data?.menu
    };

    const { setHeader } = useHeader();

    useEffect(() => {
      setHeader(HeaderData)
    }, [HeaderData]);

This code fragment used in any component which is an ancestor of a HeaderContext will cause an infinite render loop. The useEffect will fire each time HeaderData changes. But HeaderData is constructed each time the component is rendered. So the useEffect will fire each time the component is rendered, and it will call setHeader which will force a re-render, which closes the loop.

If you're trying to simply specify the initial state (and not trying to update it in response to some event), simply pass the initial state to the provider component. e.g.

// context/header.js
import { createContext, useContext, useState } from 'react';

const HeaderContext = createContext();

const defaultHeaderState = {
    siteName: data.siteBranding.siteName,
    companyLogo: data.siteBranding.companyLogo,
    menu: data?.menu
};  

export function HeaderProvider({
  initialState = defaultHeaderState,
  children
}) {
    const [header, setHeader] = useState(initialState);
    const value = { header, setHeader };

    return (
        <HeaderContext.Provider value={value}>
            {children}
        </HeaderContext.Provider>
    )
}

export function useHeader() {
    return useContext(HeaderContext);
}

Chad's answer was definitely right, directionally — don't use useEffect, and "just set it". The problem was,

how to set it from data coming in via getStaticProps?

The answer was to extract it at the _app.js level from pageProps, and pass it directly as a value to the context provider. In fact, there's no longer even a reason to use setState:

// pages/_app.js
import { HeaderProvider } from '../lib/context/header';

function MyApp({ Component, pageProps }) {

 // pull the initial data out of pageProps
 const headerData = {
    urgentBanner: pageProps.data?.urgentBanner,
    siteName: pageProps.data.siteBranding.siteName,
    companyLogo: pageProps.data.siteBranding.companyLogo,
    menu: pageProps.data?.menu
  }

  return (
    <>
      { /* pass it as a value to the context provider */ }
      <HeaderProvider value={headerData}>
        <Header { ...pageProps } /> 
      </HeaderProvider>
      <Component {...pageProps} />
    </>
  )
}

and the context provider component gets simplified down to

// context/header.js
import { createContext, useContext } from 'react';

const HeaderContext = createContext();

export function HeaderProvider({value, children}) {

    return (
        <HeaderContext.Provider value={value}>
            {children}
        </HeaderContext.Provider>
    )
}

export function useHeader() {
    return useContext(HeaderContext);
}

Finally, everywhere you want to use it (in a descendent of the context provider, HeaderProvider), you can just:

import { useHeader } from '../lib/context/header';
const headerData = useHeader();
Related