Solid.js is one of my favorite frameworks, one that I've been following closely. I haven't had the opportunity to build anything serious with it yet, but the next renderer I really want to get done for @fastify/vite
is @fastify/solid-js
.
Vue is still my professional recommendation — well, Vue to me is the absolute safest choice for any project, you simply can't go wrong with Vue.
But Solid.js is architecturally pleasing.
Ryan Carniato has written at length about it and it's really appealing to those who are cursed with the need to understand what goes on under the hood. It's a brighter destination for the React community which will enjoy the nice mixture of familiarities and strange but pleasant new concepts.
Wait, the component function runs only once!?
But I digress. I want to talk about server actions.
I think SolidStart, the official full stack framework for Solid.js, has a rather clean implementation of them. What it's interesting about SolidStart's actions is that they're isomorphic by default. If you want to ensure they run only on the server, the function must begin with the 'use server'
directive:
import { action, redirect } from '@solidjs/router';
const isAdmin = action(async (formData: FormData) => {
'use server';
await new Promise((resolve, reject) => {
setTimeout(resolve, 1000);
});
const username = formData.get('username');
if (username === 'admin') {
throw redirect('/admin');
}
return new Error('Invalid username');
});
export function MyComponent() {
return (
<form action={isAdmin} method="post">
<label for="username">Username:</label>
<input type="text" name="username" />
<input type="submit" value="submit" />
</form>
);
}
I really like this, except for the 'use server'
directive. It's giving me an OCD-level of uneasiness. I mean, it took us ages to get rid of 'use strict'
, and suddenly we're back to adding magical strings to code? A string as a statement of sorts just feels incoherent and ultimately unnecessary. It's a convention I'd rather not bring over to Fastify land, though I know it'll be inevitable when React Server Components become stable and make it into @fastify/react
. Perhaps Alexis H. Munsayac's dismantle makes it clean enough. For now I'll try to get by without it.
Isomorphic Data Fetching with @fastify/react
With @fastify/react
, it is already possible to have isomorphic data fetching very much the same way Next.js' classic getServerSideProps()
works.
The implementation is intentionally simple and easy to replicate across other frameworks — @fastify/vue
implements the same API.
If a page is being server-side rendered, getData()
runs immediately before any rendering takes places, and if a page is being client-side rendered with a client-side router, an API request is made to an endpoint automatically registered to run the getData()
function on the server. Below is an example with @fastify/react
:
import { useRouteContext } from '/:core.js'
export function getData (ctx) {
return {
message: 'Hello from getData!',
}
}
export default () => {
const { data } = useRouteContext()
return <p>{data.message}</p>
}
Now, that works well for most use-cases: for every route you can have a getData()
function responsible for isomorphic data fetching. As stated before, the code above will work seamlessly in SSR and CSR (React Router-based navigation), with all resource handling mechanics handled internally by @fastify/react
and its useRouteCountext()
hook, available from the /:core.jsx
smart import.
Designing server actions for @fastify/react
There's something to be said about the convenience of being able to define isomorphic endpoints directly from a route module. That is something about SolidStart's actions I like and think Fastify users should have access to.
I set out the make the following piece of code work:
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { createServerAction, useServerAction } from '/:core.jsx'
const accessCounter = createServerAction()
export function configure (server) {
let counter = 0
server.get(accessCounter, (_, reply) => {
reply.send({ counter: ++counter })
})
}
export default function Counter () {
const data = useServerAction(accessCounter)
const [counter, setCounter] = useState(data.counter)
const incrementCounter = async () => {
const request = await fetch(accessCounter)
const data = await request.json(0)
setCounter(data.counter)
}
return (
<>
<h1>Counter example using server action:</h1>
<p>
<Link to="/">Go back to the index</Link>
</p>
<p>Counter: {counter}</p>
<input
type="button"
value="Increment"
onClick={incrementCounter} />
</>
)
}
Before going any further, let's break it down.
First, we have the introduction of a new createServerAction()
helper from /:core.jsx
, which basically returns an endpoint as a string.
This is mostly for the convenience of not having to think about an endpoint, you know know you need one, so you can use this helper to create it.
const accessCounter = createServerAction()
Next we export a configure()
function, which @fastify/react
will automatically run when loading your route modules. It receives the Fastify server instance as first parameter, allowing you to register decorators, routes, hooks and even plugins directly from the route module if you want.
In this case, we're using it to define a POST
handler for accessCounter
:
export function configure (server) {
let counter = 0
server.get(accessCounter, (_, reply) => {
reply.send({ counter: ++counter })
})
}
The key idea here is that accessCounter never runs on the client — after all it is defined as a route handler directly in the Fastify server instance.
But we're still able to run it both during in SSR and CSR — that's what the useServerAction()
hook will be responsible for.
Diving into the implementation
In the spirit of first getting it done, I carried an implementation with the absolute minimum changes required to make it work. It'll require some finessing later, for sure. Nevertheless, it should serve as an educational walkthrough the combined power of Fastify, Vite and @fastify/vite
's configuration hooks.
You can take a peek at the PR with all of the code here.
The first thing we'll need is to extend the route context object with an extra actionData
object. We'll use that to store data for actions when doing SSR, so that we don't have to repeat the same request on the client and hydrate it instead.
We'll use it in the useServerAction()
implementation, added to virtual/core.jsx
:
let serverActionCounter = 0
export function createServerAction(name) {
return `/-/action/${name ?? serverActionCounter++}`
}
export function useServerAction(action, options = {}) {
if (import.meta.env.SSR) {
const { req, server } = useRouteContext()
req.route.actionData[action] = waitFetch(
`${server.serverURL}${action}`,
options,
req.fetchMap,
)
return req.route.actionData[action]
}
const { actionData } = useRouteContext()
if (actionData[action]) {
return actionData[action]
}
actionData[action] = waitFetch(action, options)
return actionData[action]
}
A few things to note here:
- When doing SSR (
import.meta.env.SSR
), we retrieve references to both the Fastify server instance the Fastify request object. We'll assume the presence ofserver.serverURL
, so we can do HTTP requests the same server, andreq.route.actionData
, to hold action data for each request. waitFetch()
is a helper already present invirtual/core.jsx
, but tweaked to allow passing a customMap
to track requests. When it's used on the server, it'll use aMap
added to the request (req.fetchMap
).- When we assign to
req.route.actionData[action]
, we can be sure we'll be able to access it on the client viawindow.route
, automatically hydrated fromreq.route
.
To ensure server.serverURL
and req.fetchMap
, we add a prepareServer()
hook to @fastify/vite
's renderer module, which @fastify/vite
will automatically run:
function prepareServer(server) {
let url
server.decorate('serverURL', { getter: () => url })
server.addHook('onListen', () => {
const { port, address, family } = server.server.address()
const protocol = server.https ? 'https' : 'http'
if (family === 'IPv6') {
url = `${protocol}://[${address}]:${port}`
} else {
url = `${protocol}://${address}:${port}`
}
})
server.decorateRequest('fetchMap', null)
server.addHook('onRequest', (req, _, done) => {
req.fetchMap = new Map()
done()
})
server.addHook('onResponse', (req, _, done) => {
req.fetchMap = undefined
done()
})
}
Note that we can only obtain the server's address once it's starts listening, that's why the code is set up to use to onListen
hook.
The fetchMap
is first cleanly added to the request, with an appropriate decoration added at boot time as to avoid changing the V8 shape of the object, and also cleanly removed when the response is sent.
Finally, as a bonus, @fastify/react
's accompanying Vite plugin receives a touch of acorn-strip-function
as to ensure that the configure()
and getData()
functions defined in route modules never make it to the client bundle:
- load(id) {
+ load(id, options) {
+ if (
+ !options?.ssr &&
+ !id.startsWith('/:') &&
+ id.match(/.(j|t)sx$/)
+ ) {
+ const source = readFileSync(id, 'utf8')
+ return stripFunction(stripFunction(source, 'configure'), 'getData')
+ }
const [, virtual] = id.split(prefix)
return loadVirtualModule(virtual)
},
If you're wondering why readFileSync()
is used and not its async counterpart, that's because synchronous filesystem methods tend to run faster at boot time. They would be a concern if you were calling them from a route handler.
Testing the implementation
@fastify/react@0.6.0
is available with this implementation, and the counter example is now a part of the react-kitchensink
starter template.
Download it with giget
:
giget gh:fastify/fastify-vite/starters/react-kitchensink#dev <your-app>
cd <your-app>
npm i
npm run dev
Navigate to http://localhost:3000
and click /actions/data
:
Before the page renders — which will be CSR because it's just React Router doing its job, you'll see a request triggered to the endpoint. If you refresh, i.e., perform SSR on the page, the action runs server-side only.
And on the page, clicking the button will issue new requests to the server action, but useServerAction()
will run it only once per route render.
How do you feel about this feature? Before v1.0.0
, anything is on the table with @fastify/react
. Please open an issue if you'd like to propose any changes or enhancements. In time, this feature will be replicated in @fastify/vue
.