philolegein
philolegein

Reputation: 1545

React useContext, NextJS static page generation, and rendering

I'm using React useContext to avoid prop-drilling, and building static pages in NextJS, as described in this Technouz post (NB: this is not about the NextJS getStaticProps context parameter).

The basic functionality is working; however, I can't figure out the right way to update the context from components farther down the chain.

At a high level, I have this:

// pages/_app.js
function MyApp({ Component, pageProps }) {

   const [ headerData, setHeaderData ] = useState( {
      urgentBanner: pageProps.data?.urgentBanner,
      siteName: pageProps.data?.siteBranding.siteName,
      companyLogo: pageProps.data?.siteBranding.companyLogo,
      menu: pageProps.data?.menu
    } );

  return (
    <HeaderProvider value={{ headerData, setHeaderData }}>
      <Header /> 
      <Component {...pageProps} />
    </HeaderProvider>
  )
}

// components/Header.js   
export default function Header() {
    const { headerData } = useHeader();

    return (
        <header>
            { headerData.urgentBanner && <UrgentBanner {...headerData.urgentBanner}/> }
            <Navbar />
        </header>
    )
}

// lib/context/header.js
const HeaderContext = createContext();

export function HeaderProvider({value, children}) {

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

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

The Navbar component also uses the context.

That all works. I query the data from a headless CMS using getStaticProps, and everything gets passed through pageProps, and when I run npm run build, I get all of my static pages with the appropriate headers.

But, now I'm extending things, and not all pages are the same. I use different models at the CMS level, and want to display different headers for landing pages.

Inside of [pages].js, I handle that thusly:

const Page = ({ data }) => {
    switch (data.pageType) {
      case 'landing-page':
        return (
          <PageLandingPage data={data} />
        );
      case 'page':
      default:
        return (
          <PageStandard data={data} />
        );
    }
}

Now, if we're building a static landing page instead of a static standard page, the whole hierarchy would look something like this:

<HeaderProvider value={{ headerData, setHeaderData }}>
    <Header>
        { headerData.urgentBanner && <UrgentBanner {...headerData.urgentBanner}/> }
        <Navbar>
            <ul>
                {menu && <MenuList type='primary' menuItems={menu.menuItems} />}
            </ul>
        </Navbar> 
    </Header>
    <PageLandingPage {...pageProps}> // *** Location 2
        <LandingPageSection>
            <Atf> // *** Location 1
                <section>
                    { socialProof && <SocialProof { ...socialProof } />}
                    <Attention { ...attentionDetails }/>
                </section>
            </Atf>
        </LandingPageSection>
    </PageLandingPage>
</HeaderProvider>

Location 1 and Location 2 are where I want to update the context. I thought I had that working, by doing the following at Location 1:

// components/Atf.js
export default function Atf({content}) {

    // this appeared to work
    const { headerData, setHeaderData } = useHeader();
    setHeaderData(
        {
            ...headerData,
             urgentBanner: content.find((record) => 'UrgentBannerRecord' === record?.__typename)
        }
    )

    return (
        <section>
            { socialProof && <SocialProof { ...socialProof } />}
            <Attention { ...attentionDetails }/>
        </section>
    )
}

I say "thought", because I was, in fact, getting my <UrgentBanner> component properly rendered on the landing pages. However, when digging into the fact that I can't get it to work at Location 2, I discovered that I was actually getting warnings in the console about "cannot update a component while rendering a different component" (I'll come back to this).

Now to Location 2. I tried to do the same thing here:

// components/PageLandingPage.js
const PageLandingPage = ({ data }) => {
    const giveawayLandingPage = data.giveawayLandingPage;

    // this, to me, seems the same as above, but isn't working at all
    if (giveawayLandingPage?.headerMenu) {
        const { headerData, setHeaderData } = useHeader();
        setHeaderData(
            {
                ...headerData,
                menu: { ...giveawayLandingPage.headerMenu }
            }
        );
    }    

    return (
        <div>
          {giveawayLandingPage.lpSection.map(section => <LandingPageSection details={section} key={section.id} />)}
        </div>
    )
}

To me, that appears that I'm doing the same thing that "worked" in the <Atf> component, but ... it's not working.

While trying to figure this out, I came across the aforementioned error in the console. Specifically, "Cannot update a component (MyApp) while rendering a different component (Atf)." And I guess this is getting to the heart of the problem — something about how/when/in which order NextJS does its rendering when it comes to generating its static pages.

Based on this answer, I initially tried wrapping the call in _app.js in a useEffect block:

// pages/_app.js
...
/*  const [ headerData, setHeaderData ] = useState( {
    urgentBanner: pageProps.data?.urgentBanner,
    siteName: pageProps.data?.siteBranding.siteName,
    companyLogo: pageProps.data?.siteBranding.companyLogo,
    menu: pageProps.data?.menu
  } ); */

  const [ headerData, setHeaderData ] = useState({});
  useEffect(() => {
    setHeaderData({
      urgentBanner: pageProps.data?.urgentBanner,
      siteName: pageProps.data?.siteBranding.siteName,
      companyLogo: pageProps.data?.siteBranding.companyLogo,
      menu: pageProps.data?.menu
    });
  }, []);

But that didn't have any impact. So, based on this other answer, which is more about NextJS, though it's specific to SSR, not initial static page creation, I also wrapped the setState call in the <Atf> component at Location 1 in a useEffect:

// components/Atf.js
...
    const { headerData, setHeaderData } = useHeader();
/*     setHeaderData(
        {
            ...headerData,
            urgentBanner: content.find((record) => 'UrgentBannerRecord' === record?.__typename)
        }
    ) */

    useEffect(() => {
        setHeaderData(
            {
                ...headerData,
                urgentBanner: content.find((record) => 'UrgentBannerRecord' === record?.__typename)
            }
        )
    }, [setHeaderData])

That did stop the warning from appearing in the console ... but it also stopped the functionality from working — it no longer renders my <UrgentBanner> component on the landing page pages.

I have a moderately good understanding of component rendering in React, but really don't know what NextJS is doing under the covers when it's creating its initial static pages. Clearly I'm doing something wrong, so, how do I get my context state to update for these different types of static pages?

(I presume that once I know the Right Way to do this, my Location 2 problem will be solved as well).

Upvotes: 0

Views: 1048

Answers (1)

philolegein
philolegein

Reputation: 1545

I ended up fixing this by moving from useState to useReducer, and then setting all of the state, including the initial state, at the page level. Now, _app.js is simplified to

function MyApp({ Component, pageProps }) {

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

export default MyApp

And the context hook setup uses the reducer and provides it back to the provider:

// lib/context/header.js
const initialState = {};
const HeaderContext = createContext(initialState);

function HeaderProvider({ children }) {
    const [headerState, dispatchHeader] = useReducer((headerState, action) => {
        switch (action.type) {
            case 'update':
                const newState = { ...headerState, ...action.newState };
                return newState;
            default:
                throw new Error('Problem updating header state');
        }
    }, initialState);

    return (
        <HeaderContext.Provider value={{ headerState, dispatchHeader }}>
            {children}
        </HeaderContext.Provider>
    );
}

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

export { HeaderProvider, useHeader }

Then, everywhere you want to either get the state or set the state, as long as you're inside of the <Provider>, you're good to go. This was a little confusing at first, because it's not obvious that when you useContext, what it's doing is returning the current value, and the value is provided both with the state, and with the dispatch function, so when you want to set something, you query the "value", but destructure to get the "setter" (i.e., the dispatch function).

So, for example, in my "location 2" from the initial question, it now looks like

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

const PageLandingPage = ({ data }) => {
    const giveawayLandingPage = data.giveawayLandingPage;

    // here's where we get the "setter" through destructuring the `value`
    // let foo = useHeader();
    // console.log(foo);
    // > { headerState, dispatchHeader }
    const { dispatchHeader } = useHeader();
  
    useEffect(() => {
        dispatchHeader({
          newState: {
            menu: { ...giveawayLandingPage.headerMenu }
          },
          type: 'update'
        });
    }, []);
...

Upvotes: 2

Related Questions