Islands: State

Server-to-Client State

Pass data from server (MoonBit) to client (TypeScript) with type safety.

The Flow

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                     Server                       โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚  MoonBit Struct                                 โ”‚
โ”‚  โ†“                                              โ”‚
โ”‚  derive(ToJson)                                 โ”‚
โ”‚  โ†“                                              โ”‚
โ”‚  .to_json().stringify()                         โ”‚
โ”‚  โ†“                                              โ”‚
โ”‚  HTML: luna:state='{"initial":5}'               โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                        โ”‚
                        โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚                     Client                       โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚  Luna Loader                                    โ”‚
โ”‚  โ†“                                              โ”‚
โ”‚  JSON.parse(luna:state)                         โ”‚
โ”‚  โ†“                                              โ”‚
โ”‚  TypeScript Interface                           โ”‚
โ”‚  โ†“                                              โ”‚
โ”‚  Component props                                โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Defining State

MoonBit (Server)

using @server_dom { island, button, text }
using @luna { Load }

struct CounterState {
  initial : Int
  max : Int
} derive(ToJson, FromJson)

fn counter_island(state : CounterState) -> @luna.Node {
  island(
    id="counter",
    url="/static/counter.js",
    state=state.to_json().stringify(),
    trigger=Load,
    children=[
      button([text("Count: " + state.initial.to_string())])
    ],
  )
}

TypeScript (Client)

// counter.ts
interface CounterProps {
  initial: number;
  max: number;
}

function Counter(props: CounterProps) {
  const [count, setCount] = createSignal(props.initial);

  const increment = () => {
    setCount(c => Math.min(c + 1, props.max));
  };

  return (
    <button onClick={increment}>
      Count: {count()} / {props.max}
    </button>
  );
}

hydrate("counter", Counter);

Complex State

Nested Objects

struct UserCardState {
  user : User
  settings : Settings
} derive(ToJson, FromJson)

struct User {
  id : Int
  name : String
  email : String
} derive(ToJson, FromJson)

struct Settings {
  theme : String
  compact : Bool
} derive(ToJson, FromJson)

Arrays

struct TodoListState {
  todos : Array[Todo]
  filter : String
} derive(ToJson, FromJson)

struct Todo {
  id : Int
  text : String
  done : Bool
} derive(ToJson, FromJson)

Enums (Tagged Unions)

enum Status {
  Loading
  Error(String)
  Success(Int)
} derive(ToJson, FromJson)

// Serializes as:
// Loading -> {"$tag": "Loading"}
// Error("msg") -> {"$tag": "Error", "0": "msg"}
// Success(42) -> {"$tag": "Success", "0": 42}

Security Considerations

XSS Prevention

Luna automatically escapes state to prevent XSS:

let state = UserState { name: "<script>alert('xss')</script>" }
// Escaped in HTML attribute

Sensitive Data

Never include sensitive data in state:

// BAD - exposed in HTML source
struct BadState {
  api_key : String      // Don't do this!
  password : String     // Never!
} derive(ToJson)

// GOOD - only public data
struct GoodState {
  user_id : Int
  display_name : String
} derive(ToJson)

State Size

Keep state minimal for performance:

// BAD - too much data
struct BadState {
  all_users : Array[User]        // Entire database
  entire_config : Config         // Everything
} derive(ToJson)

// GOOD - only what's needed
struct GoodState {
  current_user_id : Int
  visible_user_ids : Array[Int]  // Just IDs, fetch details client-side
} derive(ToJson)

Type Safety Tips

Keep Types in Sync

Create matching TypeScript types:

// MoonBit
struct CounterState {
  initial : Int
  max : Int
} derive(ToJson)
// TypeScript
interface CounterState {
  initial: number;
  max: number;
}

Validate at Runtime (Optional)

import { z } from "zod";

const CounterStateSchema = z.object({
  initial: z.number(),
  max: z.number(),
});

function Counter(rawProps: unknown) {
  const props = CounterStateSchema.parse(rawProps);
  // Now props is type-safe
}

Complete Example

MoonBit

struct ProductIslandState {
  product : Product
  in_cart : Bool
  user_currency : String
} derive(ToJson, FromJson)

struct Product {
  id : Int
  name : String
  price : Int
  stock : Int
} derive(ToJson, FromJson)

fn product_island(state : ProductIslandState) -> @luna.Node {
  island(
    id="product",
    url="/static/product.js",
    state=state.to_json().stringify(),
    trigger=Load,
    children=[
      div([
        h2([text(state.product.name)]),
        p([text(state.user_currency + " " + state.product.price.to_string())]),
      ])
    ],
  )
}

TypeScript

interface ProductIslandProps {
  product: {
    id: number;
    name: string;
    price: number;
    stock: number;
  };
  inCart: boolean;
  userCurrency: string;
}

function ProductIsland(props: ProductIslandProps) {
  const [inCart, setInCart] = createSignal(props.inCart);
  const [quantity, setQuantity] = createSignal(1);

  const addToCart = () => {
    setInCart(true);
    // API call to add to cart
  };

  return (
    <div>
      <h2>{props.product.name}</h2>
      <p>{props.userCurrency} {props.product.price}</p>

      <Show when={!inCart()} fallback={<p>In Cart!</p>}>
        <input
          type="number"
          value={quantity()}
          max={props.product.stock}
        />
        <button onClick={addToCart}>Add to Cart</button>
      </Show>
    </div>
  );
}

Try It

Create an island that:

  1. Passes a list of products from server

  2. Includes user's preferred currency

  3. Client can filter and add to cart

Next

Learn about Islands Triggers โ†’