π¨ My GitHub account was flagged due to some suspicious login activities (I've no idea what happened). As a result, all VanJS repos hosted in GitHub and my GitHub accounts are currently unavailable. This website (which was hosted via GitHub Pages) was also down for 2 hours (recovered after migrated to Deno Deploy). I have reached out to GitHub support team and will do my best to restore the access of my GitHub repos. Sorry for all the inconvenience. πππ
Meanwhile, to help you access the source code before GitHub repos are accessible, I have made backup repos in GitLab (you can also file issues there for feedback and support):
- VanJS: gitlab.com/vanjs-org/van
- Mini-Van: gitlab.com/vanjs-org/mini-van
- This website: gitlab.com/vanjs-org/www
Preview links in this website, including jsfiddle links and CodeSandbox links aren't working now as they require GitHub integration.
Finally, in case it helps, I sent a post to GitHub Community: #114684. If you can kindly comment and/or upvote the post, it might help draw the awareness of GitHub team to the issue.
VanJS: Fullstack Rendering (SSR, CSR and Hydration)
Requires VanJS 1.2.0 or later, and Mini-Van 0.4.0 or later.
VanJS offers a seamless and framework-agnostic solution for fullstack rendering. We will provide a walkthrough for a sample application with SSR (server-side rendering), CSR (client-side rendering) and hydration. As an outline, here are the major steps we're going to take to build the sample application:
- Define common UI components that can be shared on both server-side and client-side.
- Implement server-side script with the help of Mini-Van for serving the HTML content to end users.
- Implement client-side script with the help of VanJS for adding client-side components and enabling hydration.
The sample application requires a bare minimum of dependencies. The server-side script can be run by Node.js. We can also build a fullstack application with other JavaScript runtime like Deno or Bun. Other front-end frameworks like Vite or Astro are not required, but it should be easy to integrate with them.
The source code of the sample application can be found here with the following directory structure:
hydration-example
: Root of the sample application.src
: Source files of the application.components
: Common components that are shared on both server-side and client-side.hello.ts
:Hello
component.counter.ts
:Counter
component.
server.ts
: server-side script to serve the HTML content.client.ts
: client-side script for client-side components and hydration.
dist
: Bundled (and minified) client-side.js
files.package.json
: Basic information of the application. Primarily, it defines the NPM dependencies.
You can preview the sample application via CodeSandbox.
A Bun-based variation of this example can be found here.
package-lock.json
File
Dependencies are declared in package.json
file:
"dependencies": {
"finalhandler": "^1.2.0",
"mini-van-plate": "^0.5.6",
"serve-static": "^1.15.0",
"vanjs-core": "^1.5.0"
}
- finalhandler and serve-static: Server-side packages for serving static files (primarily used for serving
.js
files). - mini-van-plate: The Mini-Van package used for SSR.
- vanjs-core: The VanJS package used for CSR.
Shared UI Components
Now, let's build some shared UI components that can run on both server-side and client-side.
Static Component
First, let's take a look at a static (non-reactive) component - Hello
:
import { VanObj } from "mini-van-plate/shared"
interface Props {
van: VanObj
}
export default ({van} : Props) => {
const {a, div, li, p, ul} = van.tags
const fromServer = typeof window === "undefined"
return div(
p(() => `πHello (from ${fromServer ? "server" : "client"})`),
ul(
li("πΊοΈWorld"),
li(a({href: "https://vanjs.org/"}, "π¦VanJS")),
),
)
}
Compared to the Hello
component in the "VanJS by Example" page, there are following notable differences:
- The shared
Hello
component takes avan
object as its input property. This is crucial to makeHello
component cross-platform. Callers are responsible for providing thevan
object based on what's available in their specific environment so that the component can be agnostic to the execution environment. On the server-side, thevan
object from Mini-Van will be used (we can choose thevan
object fromvan-plate
mode or frommini-van
mode), whereas on the client-side, thevan
object from VanJS will be used. - We can determine if the component is being rendered on the server-side or client-side:
and show different content based on it:const fromServer = typeof window === "undefined"
This will help us differentiate whether the component is rendered from server or from client.p(() => `πHello (from ${fromServer ? "server" : "client"})
To help with typechecking if you're working with TypeScript, you can import the VanObj
type from mini-van-plate/shared
(part of the Mini-Van package: source file).
Limitations: The typechecking for tag functions and van.add
is quite limited. This is because it's hard to unify the type system across the common types between server-side and client-side.
Reactive Component
Next, let's take a look at a reactive component - Counter
:
import { VanObj, State } from "mini-van-plate/shared"
interface Props {
van: VanObj
id?: string
init?: number
buttonStyle?: string | State<string>
}
export default ({
van, id, init = 0, buttonStyle = "ππ",
}: Props) => {
const {button, div} = van.tags
const stateProto = Object.getPrototypeOf(van.state())
const val = <T>(v: T | State<T>) =>
Object.getPrototypeOf(v ?? 0) === stateProto ? (<State<T>>v).val : <T>v
const [up, down] = [...val(buttonStyle)]
const counter = van.state(init)
return div({...(id ? {id} : {}), "data-counter": counter},
"β€οΈ ", counter, " ",
button({onclick: () => ++counter.val}, up),
button({onclick: () => --counter.val}, down),
)
}
Notable differences from the Counter
component in the "VanJS by Example" page:
- Similar to the
Hello
component, it takes avan
object as its input property to make the component environment-agnostic. - You can define states and bind states to DOM nodes as you normally do on the client-side. This is because in Mini-Van
0.4.0
release, we adjusted its implementation to make it compatible to states and state-bindings related API, though with the absence of reactively (i.e.: changing a state won't lead to the update of the DOM tree), which is only possible on the client-side after hydration. - You can optionally specify the ID of the component with the
id
property. This is helpful to locate the component while hydrating. - You can optionally specify the initial counter value (default:
0
) with theinit
property. - You can optionally specify the style of the increment/decrement buttons. As illustrated later, we will see how to make the button style of the
Counter
component reactive to user selection. - We keep the
data-counter
attribute of the component in sync with the current value of the counter. This will help us keep the counter value while hydrating.
Server-Side Script: HTML Template
Now, let's build the server-side script that enables SSR:
import { createServer } from "node:http"
import { parse } from "node:url"
import serveStatic from "serve-static"
import finalhandler from "finalhandler"
import van from "mini-van-plate/van-plate"
import Hello from "./components/hello.js"
import Counter from "./components/counter.js"
const {body, div, h1, h2, head, link, meta, option, p, script, select, title} = van.tags
const [env, port = 8080] = process.argv.slice(2);
const serveFile = serveStatic(".")
createServer((req, res) => {
if (req.url?.endsWith(".js")) return serveFile(req, res, finalhandler(req, res))
const counterInit = Number(parse(req.url!, true).query["counter-init"] ?? 0)
res.statusCode = 200
res.setHeader('Content-Type', 'text/html; charset=utf-8')
res.end(van.html(
head(
link({rel: "icon", href: "logo.svg"}),
title("SSR and Hydration Example"),
meta({name: "viewport", content: "width=device-width, initial-scale=1"}),
),
body(
script({type: "text/javascript", src: `dist/client.bundle${env === "dev" ? "" : ".min"}.js`, defer: true}),
h1("Hello Components"),
div({id: "hello-container"},
Hello({van}),
),
h1("Counter Components"),
div({id: "counter-container"},
h2("Basic Counter"),
Counter({van, id: "basic-counter", init: counterInit}),
h2("Styled Counter"),
p("Select the button style: ",
select({id: "button-style", value: "ππ"},
option("ππ"),
option("ππ"),
option("πΌπ½"),
option("β«β¬"),
option("ππ"),
),
),
Counter({van, id: "styled-counter", init: counterInit, buttonStyle: "ππ"}),
),
)
))
}).listen(Number(port), () => console.log(`Try visiting the server via http://localhost:${port}.
Also try http://localhost:${port}?counter-init=5 to set the initial value of the counters.`))
The script implements a basic HTTP server with the built-in node:http
module (no web framework needed). You will probably first notice this line:
if (req.url?.endsWith(".js")) return serveFile(req, res, finalhandler(req, res))
This is to tell the HTTP server to serve the file statically if any .js
file is requested.
The bulk of the script is declaring the DOM structure of the page that is enclosed in van.html(...)
. As you can see, the expressiveness of tag functions enable us to declare the entire HTML page, including everything in the <head>
section and <body>
section.
The code declares an HTML page with one Hello
component and two Counter
components - one with the default button style, and the other whose button style can be selected by the user. Here are a few interesting things to note:
- The line:
indicates that we're choosing different JavaScript bundle files under different modes:script({type: "text/javascript", src: `dist/client.bundle${env === "dev" ? "" : ".min"}.js`, defer: true})
client.bundle.js
in dev mode whereasclient.bundle.min.js
in prod mode. It makes sense to use original client-side script during development and use the minified script in production. - We're allowing users to set the initial value of the counters via query parameters. Specifically, the line:
and line:const counterInit = Number(parse(req.url!, true).query["counter-init"] ?? 0)
enable that.Counter({van, id: "basic-counter", init: counterInit})
- We're choosing
van-plate
mode as SSR is done with pure text templating without any DOM manipulation. If you want some DOM manipulation for your SSR, you can choosemini-van
mode instead.
Client-Side Script: CSR and Hydration
The final step is to complete the client-side script file.
Client-Side Component
First, let's try to add a client-side component:
van.add(document.getElementById("hello-container")!, Hello({van}))
This will append a CSR Hello
component right after the SSR Hello
component. You can tell whether the component is rendered on the server-side or on the client-side by checking whether the text is πHello (from server)
or πHello (from client)
.
Hydration
Next, let's hydrate the counter components rendered on the server side to add the reactivity. We can use van.hydrate
to achieve that:
van.hydrate(document.getElementById("basic-counter")!, dom => Counter({
van,
id: dom.id,
init: Number(dom.getAttribute("data-counter")),
}))
van.hydrate
replaces the SSR component (located by document.getElementById("basic-counter")!
) with the CSR Counter
component. Note that the 2nd argument of van.hydrate
is the hydration function that takes the existing DOM node as its parameter and returns the new hydrated component. This way we can get the current state of SSR component (via Number(dom.getAttribute("data-counter"))
) and pass-in the information while constructing the hydrated component, which keeps the counter value the same after hydration.
In the hydration function, you can read the val
property of external states. In this way, the hydrated component will be a State
-derived node, i.e.: a DOM node that will be updated whenever its dependency states change. Now, with that, let's build a Counter
component whose button style can be adjusted by end users. First, let's define a state buttonStyle
whose val
is bound to the value
of the #button-style
<select>
element:
const styleSelectDom = <HTMLSelectElement>document.getElementById("button-style")
const buttonStyle = van.state(styleSelectDom.value)
styleSelectDom.oninput = e => buttonStyle.val = (<HTMLSelectElement>e.target).value
Next, let's make the hydrated Counter
component reactive to buttonStyle
state:
van.hydrate(document.getElementById("styled-counter")!, dom => Counter({
van,
id: dom.id,
init: Number(dom.getAttribute("data-counter")),
buttonStyle,
}))
Since buttonStyle
is passed into the Counter
component where its val
property is referenced, the hydrated Counter
component will be reactive to the change of buttonStyle
state.
Note that, this is an illustrative example to show how to make the entire hydrated component reactive to external states. In practice, the implementation of Counter
component can be optimized to only make the <button>
s' child text nodes of the Counter
component reactive to buttonStyle
state. This can be achieved by binding more localized DOM nodes (i.e.: the child text nodes of <button>
s) to the buttonStyle
state. You can check out the implementation below for an optimized Counter
component:
import { VanObj, State } from "mini-van-plate/shared"
interface Props {
van: VanObj
id?: string
init?: number
buttonStyle?: string | State<string>
}
export default ({
van, id, init = 0, buttonStyle = "ππ",
}: Props) => {
const {button, div} = van.tags
const stateProto = Object.getPrototypeOf(van.state())
const val = <T>(v: T | State<T>) =>
Object.getPrototypeOf(v ?? 0) === stateProto ? (<State<T>>v).val : <T>v
const counter = van.state(init)
return div({...(id ? {id} : {}), "data-counter": counter},
"β€οΈ ", counter, " ",
button({onclick: () => ++counter.val}, () => [...val(buttonStyle)][0]),
button({onclick: () => --counter.val}, () => [...val(buttonStyle)][1]),
)
}
API reference: van.hydrate
Signature | van.hydrate(dom, f) => undefined |
Description | Hydrates the SSR component dom with the hydration function f . |
Parameters |
|
Returns | undefined |
Demo
Now, let's check out what we have built so far. You can preview the application via CodeSandbox. Alternatively, you can build and deploy application locally by following the steps below:
- Clone the GitHub repo:
git clone https://github.com/vanjs-org/vanjs-org.github.io.git
- Go to the directory for the demo:
cd vanjs-org.github.io/hydration-example
- Install NPM packages:
npm install
- Launch the development server:
You will see something like this in the terminal:npm run dev
Try visiting the server via http://localhost:8080. Also try http://localhost:8080?counter-init=5 to set the initial value of the counter.
- By clicking the links printed in the terminal, you will go to the demo page.
- You can build the bundle for production with:
npm run build
Let's go to the demo page now. You will probably first notice the Hello
components of the demo:
You can see an SSR Hello
component followed by a CSR Hello
component.
The second part of the demo page is for hydrating the Counter
components. In real-world use cases, hydration typically happens immediately after the page load, or when the application is idle. But if we do that in our sample application, hydration will happen so fast that we won't even be able to notice how hydration happens. Thus, for illustration purpose, we introduce a <button>
where hydration only happens upon user click:
van.add(document.getElementById("counter-container")!, p(button({onclick: hydrate}, "Hydrate")))
As a result, the second part of the demo will look like this:
You can verified that all the Counter
components are non-reactive before the Hydrate
button is clicked and can be turned reactive upon clicking the Hydrate
button.
The End
π Congratulations! You have completed the walkthrough for fullstack rendering. With the knowledge you have learned, you will be able to build sophisticated applications that take advantage of SSR, CSR and hydration.