facebook / react

The library for web and native user interfaces.
https://react.dev
MIT License
228.99k stars 46.86k forks source link

React 18 - Avoiding hydration errors, but initialize client-only state directly if possible #23068

Open fabb opened 2 years ago

fabb commented 2 years ago

This question is about hydration errors and workarounds that are future-proof for React 18 partial hydration and concurrent mode.

React hydration rules say that the server rendered html needs to match the client rendered dom that is rendered during the initial render in hydrate(). Mismatches (=slight differences in dom output) can cause all kinds of weird behavior because React's virtual dom does not match the real dom. Such mismatches can happen when rendering based on information that is only available on the client side, but not on the server side, e.g. conditional rendering based on typeof window !== 'undefined', or rendering based on data from localStorage.

So this component will cause a hydration error if it is contained in the initial sever-rendered html (case 1), but it would not cause a hydration error if it only appeared later after hydration (case 2):

const MyComponent1 = () => {
    const [viewState, setViewState] = useState(() => getViewStateFromLocalStorage())
    return <button onClick={() => setViewState(oldViewState => toggledViewState(oldViewState))>{viewState}</button>
}

A common workaround is to use useEffect to apply client data only after hydration:

const MyComponent2 = () => {
    const [viewState, setViewState] = useState('A')

    useEffect(()=>{
        setViewState(getViewStateFromLocalStorage())
    },[])

    return <button onClick={() => setViewState(oldViewState => toggledViewState(oldViewState))>{viewState}</button>
}

This workaround comes with a downside: for case 2 where the component only appears later after hydration, it would still flash from showing "A" first, and then the view state from localStorage. To make the component directly show the view state from localStorage, the code in MyComponent1 would need to be used, but then the component can not be used in initial server renderings. So the component itself needs knowledge in which contexts it will be used, which is not ideal for modularity.

I currently know of no way to make the component work for both case 1 and case 2 and show the view state from localStorage directly in the initial render for case 2 without giving the component knowledge of its outer context.

I see 2 different theoretical approaches to solve this issue which are not yet possible in React AFAIK:

  1. Signal to React that the component could cause hydration errors to make it compare the dom exactly and fix it accordingly:
const MyComponent: FunctionComponent = () => {
    const [viewState, setViewState] = useState(() => getViewStateFromLocalStorage())
    return <button onClick={() => setViewState(oldViewState => toggledViewState(oldViewState))>{viewState}</button>
}

MyComponent.gracefulHydrationErrors = true
  1. Get the info from React if the current render of the component is currently rendering as part of the initial render during hydrate():
const MyComponent = () => {
    const isHydrating = useIsHydrating()
    const [viewState, setViewState] = useState(() => isHydrating ? 'A' : getViewStateFromLocalStorage())

    useEffect(()=>{
        setViewState(getViewStateFromLocalStorage())
    },[])

    return <button onClick={() => setViewState(oldViewState => toggledViewState(oldViewState))>{viewState}</button>
}

Is there already a way to solve this issue properly with available apis? The solution also needs to work with React 18 partial hydration and concurrent mode.

salazarm commented 2 years ago

@sebmarkbage @acdlite Any idea here?

Currently the only mitigation against this problem is to wrap the component causing a mismatch with a <Suspense> boundary and use unstable_avoidThisFallback to stop it from actually "Suspending" during server rendering (ie. treating it as a fragment for initial rendering but giving it a suspense boundary on the client). The result would be that on the client, on mismatch, we would render the component again but as a separate client render after the mismatch is detected. If data / requests are cached outside of React then it wouldn't be that bad, but the additional render is a bit meh. Our current thinking is that mismatches shouldn't happen but I think in practice especially when using third party libraries they are very hard to avoid (at least now, until library authors realize the damage they cause with this kind of behavior).

I think the approach of "gracefulHydrationErrors" around a specific component could make sense imo.

gaearon commented 2 years ago

I don't understand the premise of the question. I'd like to get more clarity on what's being asked before we discuss any potential APIs.

The canonical solution to a difference in client/server output is indeed useEffect that switches state on the client. Yes, it would "flash" from the server output — but this seems inherent to what you're trying to do? If initial render is HTML and it takes a while for JS to load, there's no escaping that there will be server output shown first, and after a while the client kicks in. There's no way not to have the "flash" in principle.

So I don't understand what is being asked and why useEffect is not enough here. Maybe you can describe the current behavior and the ideal behavior from the user perspective? What does the user see before and after JS loads/runs? And how does the desired solution differ from user's perspective from the useEffect solution?

sebmarkbage commented 2 years ago

getViewStateFromLocalStorage is reading mutable state during render which has other issues and considerations.

useSyncExternalStore is the API suggested to deal with external mutable state.

That API has an SSR option which is where you’re supposed to return the value that should be used on the server and the value that should be used during hydration. You can always return a default value for SSR there.

The non-hydration case doesn’t use the SSR path and so it reads the current value.

It doesn’t seem documented yet but it’s there:

https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberHooks.new.js#L1264

gaearon commented 2 years ago

Ah I think I understand the question after re-reading a few times.. So it's about how to write a component that would "skip" the unnecessary effect for next client-only mounts (since those don't need to "wait" for anything).

fabb commented 2 years ago

Ah I think I understand the question after re-reading a few times.. So it's about how to write a component that would "skip" the unnecessary effect for next client-only mounts (since those don't need to "wait" for anything).

Exactly! Sorry if I didn‘t describe it clearly enough.

fabb commented 2 years ago

Ah I think I understand the question after re-reading a few times.. So it's about how to write a component that would "skip" the unnecessary effect for next client-only mounts (since those don't need to "wait" for anything).

@gaearon do you have a suggestion on how to implement this best, or is it currently not possible with react?

gaearon commented 2 years ago

I think useSyncExternalStore should work for this?

fabb commented 2 years ago

You mean using getServerSnapshot?

shredor commented 11 months ago

Hi, is it the correct way of detecting client rendering?

const subscribe = () => () => {};
const getClientSnapshot = () => true;
const getServerSnapshot = () => false;

export const useIsClient = () =>
  useSyncExternalStore(subscribe, getClientSnapshot, getServerSnapshot);