Using React

TIP

This section is intentionally kept in sync with Using Vue (and any other future framework usage guides), because one of fastify-vite's goals is to provide the very same usage API no matter what framework you use.

Quick Start

First make sure you have degit, a CLI to scaffold directories pulling from Git:

npm i degit -g

Then you can start off with fastify-vite's base Vue 3 starter or any of the others available:

degit terixjs/flavors/react-base your-app

TIP

terixjs/flavors is a mirror to the examples/ folder from fastify-vite, kept as a convenience for shorter degit calls.

After that you should be able to cd to your-app and run:

npm install — will install fastify, vite, fastify-vite etc from package.json

npm run dev — for running your app with Fastify + Vite's development server

npm run build — for building your Vite application

npm run start — for serving in production mode

Project Structure

Some conventions are used in the official boilerplates, but they are easy to override and defined mostly by renderer adapters. For instance, fastify-vite-vue expects the client entry point to be located at entry/client.js while fastify-vite-react expects it at entry/client.jsx.

A fastify-vite project will have at the very least a) a server.js file launching the Fastify server, b) an index.html file and c) client and server entry points for the Vite application.

fastify-vite's base React 17 starter boilerplate is based on the official React SSR example from Vite's playground. The differences start with server.js, where the raw original Express-based example can be replaced with the following Fastify server initialization boilerplate:


├─ entry/
│  ├─ client.jsx
│  └─ server.jsx
├─ views/
│  ├─ index.jsx
│  └─ about.jsx
├─ index.html
├─ base.jsx
├─ routes.js
├─ main.js
└─ server.js

const fastify = require('fastify')
const fastifyVite = require('fastify-vite')
const fastifyViteReact = require('fastify-vite-react')

async function main () {
  const app = fastify()
  await app.register(fastifyVite, {
    root: __dirname,
    renderer: fastifyViteReact,
    build: process.argv.includes('build'),
  })
  await app.vite.ready()
  return fastify
}

if (require.main === module) {
  main().then((app) => {
    app.listen(3000, (err, address) => {
      if (err) {
        console.error(err)
        process.exit(1)
      }
      console.log(`Server listening on ${address}`)
    })
  })
}

module.exports = main

You may notice instantly main() doesn't call app.listen() directly. This is an established idiom to facilitate testing, that is, having a function that returns the preconfigured Fastify instance.

Notice how we also pass in build flag, based on the presence of a build command line argument. If build is true, running the following command would then trigger the Vite build for your app instead of booting the server, as it'll force process.exit() when the build is done:

$ node server.js build

TIP

This mimics the behavior of vite build, calling Vite's internal build() function and will take into consideration options defined in a vite.config.js file or provided via the vite plugin option.

The build option is already set to process.argv.includes('build') by default, but it was made explicit above as to show how fastify-vite makes your app recognize the build command.

Entry Points

The next differences from Vite's official React SSR example are the server and client entry points.

For the server entry point, instead of providing only a render function, with fastify-vite you can also provide a routes array. The render function itself should be created with the factory function provided by fastify-vite-react, createRenderFunction(), which will automate things like client hydration and add support for route hooks, payloads and isomorphic data fetching.


├─ entry/
│  ├─ client.jsx
│  └─ server.jsx
├─ views/
│  ├─ index.jsx
│  └─ about.jsx
├─ index.html
├─ base.jsx
├─ routes.js
├─ main.js
└─ server.js

import { createApp } from '../main'
import { createRenderFunction } from 'fastify-vite-react/server'
import routes from '../routes'

export default {
  routes,
  render: createRenderFunction(createApp),
}

For the client entry point, things are nearly exact the same as the original example, the only addition being the hydrate() import and call seen in the snippet below.


├─ entry/
│  ├─ client.jsx
│  └─ server.jsx
├─ views/
│  ├─ index.jsx
│  └─ about.jsx
├─ index.html
├─ base.jsx
├─ routes.js
├─ main.js
└─ server.js

import ReactDOM from 'react-dom'
import { ContextProvider, hydrate } from 'fastify-vite-react/client'
import { createApp } from '../main'

const { App, router: Router } = createApp()

ReactDOM.hydrate(
  <Router>
    <ContextProvider context={hydrate()}>
      {App()}
    </ContextProvider>
  </Router>,
  document.getElementById('app'),
)

This will pick up values serialized in window during SSR (like window.__NUXT__) and make sure they're available through useHydration(), fastify-vite's unified helper for dealing with isomorphic data. See more in Client Hydration, Route Payloads and Isomorphic Data.

Routing Setup

You can set routes directly from JSX view files, just export path:


├─ entry/
│  ├─ client.js
│  └─ server.js
├─ views/
│  ├─ index.jsx
│  └─ about.jsx
├─ index.html
├─ base.jsx
├─ routes.js
├─ main.js
└─ server.js

import { Link } from 'react-router-dom'

export const path = '/'

export default function Index () {
  return (
    <>
      <h1>Index Page</h1>
      <p>Go to <Link to="/about">/about</Link></p>
    </>
  )
}

As long as you also use the loadRoutes() helper from fastify-vite-vue/app:


├─ entry/
│  ├─ client.js
│  └─ server.js
├─ views/
│  ├─ index.jsx
│  └─ about.jsx
├─ index.html
├─ base.jsx
├─ routes.js
├─ main.js
└─ server.js

import { loadRoutes } from 'fastify-vite-vue/app'

export default loadRoutes(import.meta.globEager('./views/*.jsx'))

The following snippet is equivalent to the one above:


├─ entry/
│  ├─ client.jsx
│  └─ server.jsx
├─ views/
│  ├─ index.jsx
│  └─ about.jsx
├─ index.html
├─ base.jsx
├─ routes.js
├─ main.js
└─ server.js

import Index from './views/index.jsx'
import About from './views/about.jsx'

export default [
  {
    path: '/',
    component: Index,
  },
  {
    path: '/about',
    component: About,
  },
]

Similarly to the way createRenderFunction() works, providing a routes array in your server entry export is what ensures you can have individual Fastify route hooks, payloads and isomorphic data functions for each of your [React Router][react-router] routes. When these are exported directly from your view files, loadRoutes() ensures they're collected.

fastify-vite will use this array to automatically register one individual route for them while applying any hooks and data functions provided.

TIP

If you don't export routes, you have to tell Fastify what routes you want rendering your SSR application:

fastify.vite.get('/*')

And in this case, any hooks or data functions exported directly from your Vue files would be ignored.

Data Fetching

fastify-vite prepacks two different convenience mechanisms for fetching data before and after initial page load. Those are in essence just two different data functions you can export from your view files.

One is for when your data function can only run on the server but still needs to be accessible from the client via an HTTP API call, the other is for when your data function needs to be fully isomorphic, that is, run both on server and client exactly the same way.

Route Payloads

The first is getPayload, which is in a way very similar to getServerSideProps in Next.js, with a dash of react-ssr-prepass. Exporting a getPayload function from a view will automatically cause it to be called prior to SSR (still in the Fastify layer) when rendering the route it's associated to, but will also automatically register an endpoint where you can call it from the client. Not only that, coupled with fastify-vite's useHydration isomorphic hook, getPayload will stay working seamlessly during client-side navigation (History API via Vue Router, React Router etc). In that regard it's very similar to asyncData Nuxt.js, which will also work seamlessly during SSR and client-side navigation.

In a nutshell — navigating to a route on the client will cause an HTTP request to be triggered automatically for that route's payload data. If the route is being server-side rendered, data is retrieved on the server and just hydrated on the client on first render.

export const path = '/route-payload'

// Will absolutely only run on the server, so it's safe to
// assume req, reply and fastify references are defined 
// in the context object passed as first parameter
export async function getPayload ({ req, reply, fastify }) {
  // Simulate a long running request
  await new Promise((resolve) => setTimeout(resolve, 3000))

  return {
    message: req.query?.message || 'Hello from server',
  }
}

On the server, getPayload is set up to run automatically in the route's preHandler hook.

Providing this function will also cause a GET JSON endpoint to be set up to run it remotely. The endpoint URL is generated following the /-/payload/:url format.

If you're exporting a getPayload function from the /foobar view component, then the automatically registered endpoint will be /-/payload/foobar.

So, to recap — because this can be a little confusing at first — here's the rundown:

  • During first-render, on the server, getPayload is executed via a preHandler hook.
  • Its result is stored as req.$payload and then serialized for client hydration.
  • Both on the server and on the client, the value is available via useHydration as $payload.
  • For client-side navigation, useHydration will call the HTTP endpoint automatically.

Retrieving payload with useHydration

The useHydration hook takes a configuration object as first parameter. The purpose of this configuration object is to easily to hold a reference to the data function being used in the view.




 
 
 
 
 



 
 
 




export const path = '/route-payload'

export async function getPayload ({ req, reply, fastify }) {
  // ...
  // Omitted for brevity
  // ...
}

export default function RoutePayload () {
  const [ctx] = useHydration({ getPayload })
  return (
    <>
      <p>Message: {ctx.$payload.message}</p>
    </>
  )
}
</script>

useHydration doesn't really do anything with the getPayload, it just uses it to know what to do.

If it sees a reference to a function named getPayload, it will know getPayload is defined in that scope and it should try to retrieve a route payload — either live during SSR, hydrated on the client or retrieved via the automatically created HTTP endpoint.

TIP

As a convention, special properties associated with data fetching functions have the $ prefix.

useHydration also provides the $payloadPath function, to get the HTTP endpoint programatically. In the snippet below, a new request to the page's payload endpoint set up to be performed manually.

It also shows the context object returned by useHydration used to manage loading status, in the case where it's triggering a request to the payload HTTP endpoint client-side. To update the context object we use the update function returned by useHydration:

export default function RoutePayload () {
  const [ctx, update] = useHydration({ getPayload })
  const [message, setMessage] = useState(null)

  // Example of manually using ctx.$payloadPath()
  // to construct a new request to this page's automatic payload API
  async function refreshPayload () {
    update({ $loading: true })
    const response = await window.fetch(`${
      ctx.$payloadPath()
    }?message=${
      encodeURIComponent('Hello from client')
    }`)
    const json = await response.json()
    setMessage(json.message)
    update({ $loading: false })
  }
  if (ctx.$loading) {
    return (
      <>
        <h2>Automatic route payload endpoint</h2>
        <p>Loading...</p>
      </>
    )
  }
  return (
    <>
      <h2>Automatic route payload endpoint</h2>
      <p>Message: {message || ctx.$payload?.message}</p>
      <button onClick={refreshPayload}>
        Click to refresh payload from server
      </button>
    </>
  )
}

Learn more by playing with the react-data boilerplate flavor:

degit terixjs/flavors/react-data your-app

Isomorphic Data

The second convenience mechanism is getData. It's very similar to getPayload as fastify-vite will also run it from the route's preHandler hook.

But it resembles the classic asyncData from Nuxt.js more closely — because the very same getData function gets executed both on the server and on the client (during navigation). If it's executed first on the server, the client gets the hydrated value on first render.

TIP

When using getPayload, the client is able to execute that very same function, but via a HTTP request to an automatically created endpoint that provides access to it on the server.

Below is a minimal example — like in the previous getPayload examples, you must pass a reference to the getData function used to useHydration.

import { fetch } from 'fetch-undici'
import { useHydration, isServer } from 'fastify-vite-vue/client'

export const path = '/data-fetching'

export async function getData ({ req }) {
  const response = await fetch('https://httpbin.org/json')
  return {
    message: isServer
      ? req.query?.message ?? 'Hello from server'
      : 'Hello from client',
    result: await response.json(),
  }
}

Up until this point, the code is exactly the same as the Vue example, the only difference being that it's now importing from fastify-vite-react. The return value for useHydration differs slightly from the Vue version though — it returns an array with [ctx, update], where ctx is the hydration context and update is a function that updates it. Without Vue-like reactivity, React requires us to use a function to update the hydration context if we need to.

export default function DataFetching (props) {
  const [ctx] = useHydration({ getData })
  if (ctx.$loading) {
    return (
      <>
        <h2>Isomorphic data fetching</h2>
        <p>Loading...</p>
      </>
    )
  }
  return (
    <>
      <h2>Isomorphic data fetching</h2>
      <p>{JSON.stringify(ctx.$data)}</p>
    </>
  )
}

Notice how this example uses fetch-undici to provide isomorphic access to fetch.

To recap:

  • During first-render, on the server, getData is executed via a preHandler hook.
  • Its result is stored as req.$data and then serialized for client hydration.
  • Both on the server and on the client, the value is available via useHydration as $data.
  • For client-side navigation, useHydration executes the getData function isomorphically.