Tutorial 3 of 21 · for app developers

Add a working cart

You'll build a cart whose contents live in the session on the server. Adding an item is one typed call from the browser. There is no cart store, no reducer and no client state to keep in sync, because the cart was never in the browser to begin with.

Before you start

Straight from crates.io. No Node, no package manager.

cargo install snapfire_compiler
cargo install snapfire_fsr_cli
fsr --version

Every command and screenshot on this page was captured with fsr 0.x.

Define the session

A schema file is the contract. FSR reads it at build time and generates the types both halves use.

ts
export interface Session {
  cart: Record<string, bigint>;
}

export const defaults: Session = {
  cart: {},
};

Save that as app/schemas/session.ts. defaults is what a visitor starts with, so nothing has to check whether the cart exists yet.

Define what the actions take

ts
export interface AddToCart {
  product_id: bigint;
  quantity: bigint;
}

export interface RemoveFromCart {
  product_id: bigint;
}

That goes in app/schemas/cart.ts. bigint isn't decoration. It crosses the wire as a real wide integer rather than a float that silently loses precision past 2^53.

Write the actions

An action is a function that runs on the server and is callable by id from the browser. Put these in app/routes/cart/actions.ts and they belong to the /cart route.

ts
import { action, fail } from "@snapfire/fsr";
import type { ActionCtx } from "@snapfire/fsr";
import type { AddToCart, RemoveFromCart } from "@schemas/cart";

export const addToCart = action(async ({ input, session }: ActionCtx<AddToCart>) => {
  const key = String(input.product_id);
  const wanted = (session.cart[key] ?? 0n) + input.quantity;
  if (wanted <= 0n) delete session.cart[key];
  else session.cart = { ...session.cart, [key]: wanted };
  const count = Object.values(session.cart).reduce((n, q) => n + q, 0n);
  return { lines: session.cart, count };
});

export const removeFromCart = action(async ({ input, session }: ActionCtx<RemoveFromCart>) => {
  const key = String(input.product_id);
  delete session.cart[key];
  const count = Object.values(session.cart).reduce((n, q) => n + q, 0n);
  return { lines: session.cart, count };
});

session is the visitor's, already loaded. Assigning to it's what persists it. input arrived typed as AddToCart because the schema said so. A request whose body doesn't match is refused before your code runs.

Read it back in the loader

The loader joins the session's quantities against whatever your catalogue is.

ts
import type { Ctx } from "@snapfire/fsr";

export async function load({ session, services }: Ctx<"/cart">) {
  const catalog = await services.shopping.listProducts({});
  const lines = catalog
    .filter((p) => session.cart[String(p.id)])
    .map((p) => ({ ...p, quantity: session.cart[String(p.id)] }));
  return { lines };
}

filter and map lower, so this whole join runs in Rust. services.shopping is a typed client FSR generated from an OpenAPI document, which is a story for another tutorial.

Call it from the page

tsx
import { actions, type CartProps } from "@generated/client";

export default function Cart({ lines }: CartProps) {
  const items = lines.reduce((n, l) => n + Number(l.quantity), 0);

  async function change(productId: bigint, delta: bigint, name: string) {
    try {
      const result = await actions.cart.addToCart({ product_id: productId, quantity: delta });
      if (!(String(productId) in result.lines)) removedFromCart(name);
    } catch (e) {
      failed(e);
    }
  }

  return <p>{items} in your cart</p>;
}

actions.cart.addToCart is generated. Pass it the wrong shape and the build tells you, not the browser. What comes back is the action's return value, typed.

Add a guard

A guard runs before anything else the action would do. If it refuses, no service is called and nothing is written.

ts
export const checkout = action(async ({ session, services }: ActionCtx) => {
  const lines = Object.entries(session.cart).map(([id, quantity]) => ({ product_id: BigInt(id), quantity }));
  if (lines.length === 0) fail("invalid", "the cart is empty");
  const order = await services.shopping.placeOrder({ lines });
  session.cart = {};
  return order;
});

fail("invalid", ...) becomes an ActionFailure in the browser with a status the host chose from the kind. An empty cart never opens a socket to the order service.

No store, no reducer

No store, no reducer, no context provider. No effect syncing the cart to localStorage. No optimistic update to reconcile, because the server holds the only copy.

Next up: 040. Let FSR cache it for you, where the pages that no longer change get answered before your code runs.

Built with SnapFire FSR. Pure Rust runtime, zero Node.js on the server.

Proudly Created by Excerion Sun LLC