0

I have an issue where I have a simple React.Context that's populated after all the components mount. The problem is that because it happens after mount, nextjs does not see this data on initial render, and so there's noticeable flicker.

Here's the simple component that sets the Context:

export const SetTableOfContents = (props: { item: TableOfContentsItem }) => {
  const toc = useContext(TableOfContentsContext);

  useEffect(() => {
    // Updates the React.Context after the component mount
    // (since useEffects run after mount)
    toc.setItem(props.item);
  }, [props.item, toc]);

  return null;
};

Here's the React.Context. It uses React state to store the TOC items.

export const TableOfContentsProvider = (props: {
  children?: React.ReactNode;
}) => {
  const [items, setItems] = useState<TableOfContents["items"]>([]);

  const value = useMemo(() => {
    return {
      items,
      setItem(item: TableOfContentsItem) {
        setItems((items) => items.concat(item));
      },
    };
  }, [items]);

  return (
    <TableOfContentsContext.Provider value={value}>
      {props.children}
    </TableOfContentsContext.Provider>
  );
};

Currently, it is not possible to set the React.Context before mount because React gives a warning---Cannot update state while render.

The only workaround I can think of is to use something other than React.state for the React.Context state---that way the component can update it any time it wants. But then the problem with that approach is that Context Consumers would no longer know that the items changed (because updates live outside the React lifecycle)!

So how to get the initial React.Context into the initial SSR render?

const items = [];

export const TableOfContentsProvider = (props: {
  children?: React.ReactNode;
}) => {
  const value = useMemo(() => {
    return {
      items,
      setItem(item: TableOfContentsItem) {
        items[item.index] = item;
      },
    };
  // this dep never changes.
  // when you call this function, values never change
  }, [items]);

  return (
    <TableOfContentsContext.Provider value={value}>
      {props.children}
    </TableOfContentsContext.Provider>
  );
};

U Avalos
  • 6,538
  • 7
  • 48
  • 81

1 Answers1

0

Here's what I ended up doing:

  • render the app in getStaticProps using renderToString
  • use useRef for state in the Context instead of useState
  • the reason for doing this is because renderToString renders only the initial state. So if you update the Context using useState, it won't capture subsequent renders
  • update the Context on component initialization for the reason mentioned above
  • pass the Context an "escape hatch"---a function we can call to get the state calculated on the initial render

Yes, the whole thing seems like a giant hack! :-) I'm not sure if React.Context plays well with SSR :(

export const TableOfContentsProvider = (props: {
  initialItems?: TableOfContentsItem[];
  setItemsForSSR?: (items: TableOfContentsItem[]) => void;
  children?: React.ReactNode;
}) => {
  // use useRef for the reasons mentioned above
  const items = useRef(props.initialItems || []);
  // Client still needs to see updates, so that's what this is for
  const [count, setCount] = useState(0);

  const { setItemsForSSR } = props;

  const setterValue = useMemo(
    () => ({
      setItem(item: TableOfContentsItem) {
        if (!items.current.find((x) => x.id === item.id)) {
          items.current.push(item);
          items.current.sort((a, b) => a.index - b.index);
          setCount((count) => count + 1);
          setItemsForSSR?.(items.current);
        }
      },
    }),
    [setItemsForSSR]
  );

  const stateValue = useMemo(() => ({ items: items.current, count }), [count]);

  return (
    <TableOfContentsSetterContext.Provider value={setterValue}>
      <TableOfContentsStateContext.Provider value={stateValue}>
        {props.children}
      </TableOfContentsStateContext.Provider>
    </TableOfContentsSetterContext.Provider>
  );
};

interface TableOfContentsSetterWorkerProps {
  item: TableOfContentsItem;
  setItem: (item: TableOfContentsItem) => void;
}

export class TableOfContentsSetterWorker extends React.Component<
  TableOfContentsSetterWorkerProps,
  {}
> {
  constructor(props: TableOfContentsSetterWorkerProps) {
    super(props);
    // Need to do this on init otherwise renderToString won't record it
    props.setItem(props.item);
  }

  render() {
    return null;
  }
}

/**
 * Usage: use this as a child component when the parent needs to set the TOC.
 *
 * Exists so that a component can set the TOC without triggering
 * an unnecessary render on itself.
 */
export function TableOfContentsSetter(props: { item: TableOfContentsItem }) {
  const { setItem } = useContext(TableOfContentsSetterContext);

  return <TableOfContentsSetterWorker item={props.item} setItem={setItem} />;
export const getStaticProps = async () => {
  let initialTableOfContents: TableOfContentsItem[] = [];
  const getItems = (items: TableOfContentsItem[]) => {
    initialTableOfContents = [...items];
  };

  const app = () => (
    <TableOfContentsProvider setItemsForSSR={getItems}>
      <AppArticles />
    </TableOfContentsProvider>
  );

  renderToString(app());

  return {
    props: {
      initialTableOfContents,
    },
  };
};
U Avalos
  • 6,538
  • 7
  • 48
  • 81