Fresh logo

Serializable types

Fresh renders pages on the server and hydrates them in the browser. Whenever a value crosses that boundary it goes through Fresh’s serializer. This includes island props, the arguments to a makeComputed, factory, or fresh/events handler, and anything else that travels from a route to the client.

This page is the reference for what the serializer accepts. Values listed below survive the round-trip and arrive on the client as either an equivalent value (for primitives and plain data) or a live equivalent (for signals and functions). Everything else has to be reshaped into one of these types before it crosses the boundary.

Basic types

Category Types
Primitives string, number (including NaN, ±Infinity, -0), boolean, null, undefined, bigint
Built-in objects Date, RegExp, Map, Set, plain objects, arrays
Typed arrays Uint8Array, Int8Array, Uint16Array, Int16Array, Uint32Array, Int32Array, Float32Array, Float64Array

Container values are checked recursively. A plain object whose properties are all serializable is itself serializable. An array that contains a Promise is not.

Typescript  
<Counter start={3} label="Clicks" startedAt={new Date()} tags={new Set(["a", "b"])} />

Circular and shared references

The serializer tracks every object it visits. If it sees the same object twice it records a reference instead of writing the value again. This means the revived graph on the client has the same shape as the original.

Typescript  
const node = { name: "root", children: [] as unknown[] };
node.children.push(node); // a cycle

<Tree node={node} />; // serializes fine; revives with the same cycle

The same applies to plain shared references. Two props that point at the same object end up pointing at one shared object on the client too. Mutating it through one prop is visible through the other.

Signals

Signals are serializable as long as their inner value is serializable. A signal passed across the boundary revives as the same live signal on the client. This means a parent route can own a piece of state and share it across islands. Each island reads and writes the same signal.

Typescript routes/index.tsx
import { page } from "./$index.ts";
import { useSignal } from "@preact/signals";
import { Display } from "../islands/Display.tsx";
import { Controls } from "../islands/Controls.tsx";

export default page(() => {
  const count = useSignal(0);
  return (
    <>
      <Display count={count} />
      <Controls count={count} />
    </>
  );
});

A plain computed(...) is not serializable. The derivation function can’t be reproduced on the client. For derived values that need to cross the boundary, use makeComputed from fresh/signals instead.

Functions

Most JavaScript functions are not serializable. The function body is JavaScript source, and there is no general way to ship arbitrary source to the browser and rebuild a working closure.

The way around this is the islands/ folder. Files under islands/ (or a co-located (_islands)/ folder) are compiled separately and shipped to the client. So a function exported from one of those files has a known client-side counterpart, and Fresh can reference it from the serialized payload.

There are two patterns for serializable functions, and both require the export to live under islands/.

Plain

A plain serializable function is just a regular function exported from an island file. Import it from a route and pass it as a prop or event handler. The same function runs on the client when the event fires.

This works for any behavior that does not depend on values created in the route.

Typescript islands/actions.tsx
export function greet() {
  alert("Hello!");
}
Typescript routes/index.tsx
import { page } from "./$index.ts";
import { greet } from "../islands/actions.tsx";

export default page(() => <button onClick={greet}>Greet</button>);

Factory

A factory is a wrapper exported from an island file. It takes call-site arguments and returns a value (usually a closure). The wrapper ships a small recipe to the client: the wrapper itself, plus the arguments it was called with. The client re-runs the recipe to rebuild the value. This is how you can create new closures at the call site of a route and still have them survive to the browser.

Define a factory with factory(fn) from fresh/serializable.

Typescript islands/handlers.tsx
import { factory } from "fresh/serializable";
import type { Signal } from "@preact/signals";

export const incrementBy = factory((count: Signal<number>, step: number) => () => {
  count.value += step;
});
Typescript routes/counter.tsx
import { page } from "./$counter.ts";
import { useSignal } from "@preact/signals";
import { incrementBy } from "../islands/handlers.tsx";

export default page(() => {
  const count = useSignal(0);
  return (
    <div>
      <button onClick={incrementBy(count, 1)}>+1</button>
      <button onClick={incrementBy(count, 10)}>+10</button>
      <span>{count}</span>
    </div>
  );
});

There are two extra constraints on factories beyond living under islands/. The wrapper’s return value must be an object or a function (primitives can’t carry the bookkeeping that lets Fresh rebuild them; to return a primitive, wrap it in { value: 42 }). And each call-site argument must itself be serializable.

The built-in event factories (setValue, add, toggle, setFromProp) are all defined this way.

Plain or factory?

The rule of thumb is whether the function needs to close over state created in the route.

Use a plain function when the behavior is the same every time it runs. greet above always pops the same alert. It needs nothing from the call site. Importing it from the route and passing it as a handler is enough.

Use a factory when the function needs to capture per-render state. The most common case is a route that creates a signal with useSignal and wants to write to it from an event handler. The route can’t ship the inline () => count.value++ closure to the client, but it can call a factory with count as an argument. The factory returns a closure that closes over that specific signal, and the client rebuilds an equivalent closure bound to the same revived signal.

Inside an island you don’t need any of this. The closure runs on the client to begin with, so an inline onClick={() => count.value++} is fine. Factories are for handlers and callbacks created in a route that need to cross to the client.

What is not serializable

Passing one of these to a server-rendered island prop, factory argument, or event handler raises an error at SSR time.

  • Plain JavaScript functions and closures that are not an island export or a factory(...) result.
  • Plain computed(...) signals. Use makeComputed instead.
  • Class instances other than the built-ins listed above. Custom classes lose their prototype on the round-trip.
  • DOM nodes, Element, Event, and other browser-only references.
  • Symbol values.
  • Promise values, async iterators, and generators.
  • WeakMap and WeakSet.

If you need to send something in one of these categories, reshape it first. Pre-fetch a Promise and pass its resolved value. Project a class instance to a plain object. Move the closure into an island and import it from there.