Skip to content

Data & state

State & Context

Share one live object across islands. A class with static wire = import.meta.og.wire({…}) crosses island boundaries as a prop, and a drop-in setContext or <Provide> reaches a whole subtree without prop drilling.

On this page

Each island is its own hydration root, so a value passed between islands crosses a serialization boundary instead of being shared by reference. For state that lives inside one island, a plain $state is enough, and for app-wide state, a module-level $state already shares across islands with no setup. The tools on this page are for the remaining case, where an instance is born on the server and has to reach other islands. There are two of them: sharing state hands one live object to specific islands as a prop, and context provides a value to a whole subtree so that any island below can read it.

Sharing state

A plain object copies correctly across a serialization boundary, but a live class instance, such as a store or an orchestrator, does not. A class opts in by declaring how it travels, with the wire macro:

export class Cart {
  items = $state<string[]>([]);
  get count() { return this.items.length; }
  add(item: string) { this.items.push(item); }

  static wire = import.meta.og.wire({
    encode: (c: Cart) => $state.snapshot(c.items),
    decode: (items: string[]) => Object.assign(new Cart(), { items }),
  });
}

encode says what leaves and decode says how to rebuild it. You write both, so nothing is hidden. import.meta.og.wire is a compile-time construct with no import, and the macro generates the codec key. The wire macro page has the full contract, the strict rules and session continuity (id and merge).

Create one instance and pass it to as many islands as you like:

<script>
  import CartCount from './CartCount.svelte' with { wake: 'load' };
  import AddButton from './AddButton.svelte' with { wake: 'visible' };
  import { Cart } from './cart.svelte.js';

  const cart = new Cart();
</script>

<CartCount {cart} />
<AddButton {cart} />

The two islands are separate bundles, but they share one live object: when you click Add, the count island repaints. The $state inside the instance is reactive across every island that holds it, without a store library or hand-written events.

The two widgets below are separate islands that share one Cart, passed as a prop:

Count island
0 items in the shared cart
Add island

separate bundle from the count

How it stays live

What keeps the object live is identity, not the codec. Each instance gets one wire id, and the browser rebuilds it once and memoizes it by that id, so every copy of the prop resolves to the same object. An island that hydrates late (visible, idle) joins the current instance when it wakes.

Server-safe by construction

The browser remembers decoded instances, but the server never does. Every request, and every deferred-island render, decodes fresh, so one visitor’s state can never render into another visitor’s HTML. Create the instance per request, in your page or in load, the same as any other prop. The encode snapshot rides inside the props, so the server HTML and the first client paint agree and nothing flickers.

Context

When one object goes to two known islands, a prop is the right tool. Sometimes, though, a value has to reach many islands spread through a subtree, such as a cart shared by a header badge and a button in the page body, a theme or the current user, and threading it through every prop is noise. With context, you provide the value once and each island below reads it with ordinary getContext.

Svelte’s own getContext and setContext work down a component tree, and islands break that tree, so a setContext above an island never reaches inside it on the client. ogygia bridges the same getContext('key') across the split, using the one thing that does continue across it, the DOM. You can provide values in two ways, with a drop-in setContext or with <Provide>. What can cross, how stores stay live across islands and how to pass functions make up the boundary’s contract, which The Boundary describes.

Drop-in setContext

For a layout you already have, the smallest change is to swap the import:

// import { setContext } from 'svelte';
import { setContext } from 'ogygia';

setContext('user', currentUser);   // now reaches child islands too

It does exactly what Svelte’s setContext does, and on the server it also records each string-keyed value, so every island can seed its own getContext('user') from it. The result is one flat page root that every island on the page inherits. Symbol keys and function values stay Svelte-only, because they cannot be serialized and so cannot cross. For a value scoped to only part of the tree, use <Provide>, which shadows the page root on the same key.

Define a typed context

createContext(key) is optional TYPED sugar over a string key. You call it to make a provider entry, and .get() reads the typed value. The key is the same string that plain getContext uses, so typed and untyped code read the same value.

import { createContext } from 'ogygia';
import { Cart } from './cart.js';

export const cartCtx = createContext<Cart>('cart');                       // get(): Cart | undefined
export const theme = createContext<'light' | 'dark'>('theme', 'light');   // has a default

Provide it

<Provide values={…}> wraps a subtree and provides its values to every island inside. You can pass a typed handle called with its value (cartCtx(cart) → { cart }), a plain object, or an array of them, merged left to right with falsy entries skipped:

<script>
  import { Provide } from 'ogygia';
  import CartBadge from './CartBadge.svelte' with { wake: 'load' };
  import AddButton from './AddButton.svelte' with { wake: 'visible' };
  import { Cart, cartCtx } from './cart.svelte.js';

  const cart = new Cart();
</script>

<Provide values={[cartCtx(cart)]}>
  <CartBadge />
  <!-- ...any depth of markup and islands... -->
  <AddButton />
</Provide>

There is no .set(), because <Provide> is the provider and has to wrap a subtree. To change the value, mutate the live object you provided, and every reader sees the change.

Read it

An island under the provider reads the value during setup, with either the typed handle or plain getContext, which return the same value:

<script>
  import { getContext } from 'ogygia'; // or from 'svelte' — same function
  import { cartCtx } from './cart.svelte.js';

  const cart = cartCtx.get();        // typed — the provided Cart, or undefined
  // const cart = getContext('cart'); // untyped — same key, same value
</script>

<span>{cart?.count ?? 0}</span>

Read it during the island’s setup, exactly as with Svelte’s getContext, and keep the reference. For a wired value, that reference is the one live instance, shared and reactive across every island that read it.

getContext is re-exported from ogygia only for symmetry; it is Svelte’s own function, unchanged. ogygia seeds each island’s context when it hydrates, so a plain read already sees bridged values. Import it from wherever reads best, for example import { setContext, getContext } from 'ogygia' to keep the pair together.

The two widgets below are separate island bundles, and neither receives a prop. Both call cartCtx.get() and reach the same live Cart, so clicking Add repaints the count island:

Count island
0 read from context, no prop
Add island

separate bundle, same context

How it crosses the boundary

Both kinds of provider do two things at once:

  • On the server, islands render nested inside the page’s SSR tree, so the provider calls Svelte’s native setContext. Nested islands see the value in the server pass, and the first paint is correct.
  • The provider also writes the values into the DOM as a small <script>: a <Provide> wraps its subtree, and a drop-in setContext emits one page-level marker. The values are serialized with the same codec as island props. On the client, each island is a separate root that reads the providers standing above the spot where it hydrates, and seeds its own getContext from them.

On a `csr = true` page it is plain Svelte context

When Kit hydrates the whole document, ogygia islands degrade to plain components in one tree, so there is no split to bridge. There, setContext, createContext and <Provide> behave exactly like Svelte’s native context, with same-tree reads, no DOM bridge and no error. It is safe to keep importing the drop-in from ogygia in mixed apps: a csr = true route pays nothing for it and it works as expected, because the bridge only engages where islands split the tree.

Live objects vs plain values

Sharing and context both accept any serializable value, whether a string, a number, a plain object or an array. The difference lies in what the reader gets back:

  • A plain value comes back as a snapshot, a copy taken when you provided it. Each island gets its own copy, so a change in one is not seen by the other. This suits a theme, a user name or configuration.
  • A wired class comes back as one live instance, because the wire id resolves every decode to the same shared, reactive object. Use this when islands have to react to each other’s changes.

So value={'dark'} gives each island the string 'dark', while value={new Cart()} gives every island the same live cart.

Continuity: surviving navigation

By default, state has page lifetime: a navigation creates a fresh instance, and the visitor’s cart is gone. If you give a codec a name, it gets session lifetime, and the same live object follows the visitor across every page in the tab.

static wire = import.meta.og.wire({
  id: 'cart',                       // a name → session lifetime (one per tab)
  encode: (c) => $state.snapshot(c.items),
  decode: (d) => new Cart(d),
  merge: (live, fresh) => {         // both exist after a navigation — reconcile INTO live
    live.serverStamp = fresh.serverStamp;   // pull server truth (prices, stock, auth)
  },
});
  • On the next page the server still sends a fresh snapshot, but because the codec is named, the client joins it with the cart the visitor already had, runs merge, and gives every island the same live instance, so identity never resets.
  • By default the live object wins. Without a merge, the visitor’s in-progress cart takes precedence over the fresh server read, so write merge to pull the server’s truth in.
  • It is scoped to the tab, not the server. The name lives in memory in the browser tab (the “Keep”), so another visitor never sees it, the server never remembers it, and a reload starts fresh. A cart that has to survive reloads or follow a login needs real storage, meaning a database plus a load, and merge is where the two meet.

A context whose value is a named codec is continuous automatically, with no change to the context needed.

Persistent islands

An island can keep its whole live self across navigation, not only an object:

import Player from '$lib/Player.svelte' with { wake: 'load', keep: 'player' };

When the next page uses the same keep name, the live node and its mounted app move into the new slot instead of remounting, so a music player keeps playing through navigation and keeps its scroll position. The new page’s props are pushed into the live component, so a track that changes per route still updates. Only the app and its $state are kept, not the props.

keep needs the SPA router, because a full page navigation replaces the whole DOM and leaves nothing to move. In dev, a keep island on a page without the router warns.

Half-filled forms

This is on by default and needs no setup: an island form you partly filled in survives navigating away and back within the tab session, and is restored with bind: synced again. Turn it off for one field with data-ogygia-no-keep (useful for a one-time code or a fresh message box), or for everything with ogygia({ router: { forms: false } }).

Continuity ids must be unique per class. If two different classes are both named 'cart', they overwrite each other in the session Keep, and dev warns.

Constraints

  • A wire codec needs a top-level export class in your app source, because the build tags it by module path plus name, so default exports and class expressions don’t work. A context needs an explicit string key, either createContext('key') or the string you pass to setContext or <Provide>, shared by the provider and every reader.
  • A receiving island has to import a wire class as a value (not import type) only if it constructs one. To only receive it, as a prop or through context, nothing extra is needed, because the build registers the codec automatically.
  • A default decode that calls new Cls() needs a constructor with no arguments. Otherwise, write decode yourself (as above).
  • Read context during island setup, like getContext. A later call, for example in an event handler, has no hydrating island to anchor the read, so read once and keep the reference.
  • A provider must sit above the consuming islands: <Provide> above them in the DOM, or a drop-in setContext anywhere on the page (the flat page root). Without a provider above, the reader gets the default or undefined.
  • Context values must be serializable, like island props: plain data, a [ogygia.wire] live value or a region snippet. Functions and live stores are dropped rather than crashing the page.
  • A server island (render: 'deferred') renders in isolation on the endpoint, so it sees only a context’s default and never the page’s provider. If it needs the live context, put the reader in a nested wake island, which hydrates on the client and joins the provider.
  • import.meta.og.wire is a compile-time construct. The macro generates the reserved codec key at build time, so there is nothing to import and no runtime symbol to manage.

See Constraints for the full boundary rules.