DEV Community

Vesa Piittinen
Vesa Piittinen

Posted on • Edited on

Understanding React SSR, SPA, hydration

I have never used Gatsby, but I found myself reading about this issue on hydration bugs on production sites. Long story short: due to Gatsby preferring fast development you will not get hydration warnings of server side generated content by React during development. Thus you miss hydration issues which result into broken layout.

Instead of focusing on Gatsby I'm going to bring up the whole generic universal JavaScript issue when working with SSR and SPA, and suggest a solution to the hydration problem. Wouldn't be a programmer if I didn't!

Core of the issue

When doing this sort of universal rendering in React you have three modes:

  1. Server static HTML generation / "Server mode"
  2. Client hydration of DOM (based on the parsed static HTML) / "Hydrate mode"
  3. Client side renders / "SPA mode"

When in SPA mode you can render whatever you like: any logic or condition you use is free to use to figure out what to render. This is where you're in when doing development on Gatsby, or "hot reloading" as it was used to be called when first introduced to the React world.

A more challenging part is understanding React processed on server, and React hydration on existing DOM. People may come up with stuff like this:

function MyComponent({ isLoggedIn }) {
    if (typeof window === 'undefined') {
        return null
    }

    if (isLoggedIn) {
        return 'Hello world!'
    }

    return 'Please login!'
}
Enter fullscreen mode Exit fullscreen mode

The idea of this code is to target situation when you're doing a server mode render. A problem arises during hydrate mode, because you won't end up rendering null there and instead jump into client side rendering that is aware of client only state. This results into a mismatch with server render and client render, and during development React will warn you about this upon refreshing the page (Warning: Expected server HTML to contain a matching <x> in <y>).

The way to work around this issue is to understand you have to treat server mode and hydrate mode as equals. We can call it initial render to make it a bit easier as a mental model, because regardless of running React on server or client it is the first render. It is also typically the render where there is no user specific state. We do this to optimize server performance by caching the HTML.

A bad thing about React is that they do not provide a tool to know if we are done with the initial render or not! There is no function that you could call. This has then resulted into people working around the issue on their own, and not always ending up with the most bulletproof results.

A naive solution

If you're having difficulties getting a hang of the above please read on how to avoid rehydration problems. It is a longer take on the problem but gives another kind of view on this issue which may work better for some people.

The suggested solution in the article is the following:

function ClientOnly({ children, ...delegated }) {
    const [hasMounted, setHasMounted] = React.useState(false)
    React.useEffect(() => {
        setHasMounted(true)
    }, [])
    if (!hasMounted) {
        return null
    }
    return (
        <div {...delegated}>
            {children}
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode

The problem here is that this is component specific! This means hasMounted will always be false during the first render of a component even when we are already in SPA mode and we are no longer doing initial render of the App. This means you will have two renders in SPA mode even though you'd need only one. This of course isn't most often a big issue, but these things can become one and ideally you should always go for the least work done possible.

There are also solutions that avoid useState such as this useIsMounted implementation, but with that you instead have a problem that in the context of having finished initial render you're not getting a second render so that the UI would reflect SPA state. You would show the static HTML until something forces a re-render. And the main use of isMounted is to know whether you can still be finishing with asynchronous operations or not, so the earlier code above calling it hasMounted is not really matching what isMounted is really meant for.

A solution without double renders on SPA mode

Years ago in a codebase I worked with I ended up with a simple Redux solution to this issue: I added a serverMode boolean to the root of the store and then changed it in the main App component:

// NOTE: default value for serverMode = true
class App extends React.Component {
    componentDidMount() {
        this.props.dispatch(setServerMode(false))
    }
}
// or as hooks:
function App() {
    const dispatch = useDispatch()
    useEffect(() => {
        dispatch(setServerMode(false))
    }, [])
}
Enter fullscreen mode Exit fullscreen mode

Which then enabled to check for the condition:

function MyComponent() {
    const serverMode = useSelector(state => state.serverMode)

    if (serverMode) {
        return null
    }

    return 'Now in SPA mode'
}
Enter fullscreen mode Exit fullscreen mode

The advantage here is that now once we have hydrated we will always be in SPA mode and thus will not have the issue that we would have using useIsMounted with double renders. And we get to detect the initial render.

The downside is that we now have a dependency on Redux with essentially something that is just a single boolean that will never be changed again, but the component is still registered to listen to the Redux store changes even if it otherwise wouldn't need to be connected to Redux. This can be harmful to performance.

Getting hooked and solving all the issues

As we're using hooks with modern React it would make sense to make something similar to useIsMounted, but make it global to the App and name it more appropriately for the use. For this we can make use of useState with a single call useEffect and add an abstraction that uses the Context API so that we can create a solution similar to what I did with Redux and serverMode previously.

const HydrateContext = createContext('hydrated')

export function useIsHydrated() {
    return useContext(HydrateContext)
}

export function IsHydratedProvider({ children }) {
    const [isHydrated, setIsHydrated] = useState(false)
    useEffect(() => {
        setIsHydrated(true)
    }, [])
    return (
        <HydrateContext.Provider value={isHydrated}>
            {children}
        </HydrateContext.Provider>
    )
}
Enter fullscreen mode Exit fullscreen mode

We do not need to ever restore it back to false as this value is intended to be true after hydration for the lifetime of the App, and once App has gone we don't the value for any other use. We only need it to "force" the second render after hydrating static HTML. In comparison isMounted is appropriate for the cases where asynchronous operations might still be flying.

As for using the above code:

function MyComponent() {
    const isHydrated = useIsHydrated()
    return !isHydrated ? 'Initial render' : 'SPA mode'
}

function App() {
    return (
        <IsHydratedProvider>
            <MyComponent />
        </IsHydratedProvider>
    )
}
Enter fullscreen mode Exit fullscreen mode

Now we no longer depend on Redux and there will ever be only one time after initial render on client side that the SPA mode is toggled on where we can fill stuff based on state we only have access to on the client, such as greetings with the user's name.

Now I only hope I didn't screw up with the above as I was lazy here to boot up a new SSR + SPA React app to make sure the code works! I blame the summer heat for the laziness :)

The remaining open topic is actually naming related: is isHydrated clear enough, or would reversing the boolean value and call it isInitialRender be better? I guess that might be perfectly up to the reader to decide :)

Top comments (1)

Collapse
 
windmaomao profile image
Fang Jin

Thank you for the article. Seems to me, the same DOM elements switch to be controlled by server first and then the client. Is that right?