Tutorial 3 of 54 · for app developers · Markdown

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. The server holds the only copy of the cart, so every page reads the same quantities from the session.

Before you start

Install from crates.io. You need no Node and 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#

FSR reads a schema file at build time and generates the types that the server and the browser both use.

app/schemas/session.ts
export interface Session {
  cart: Record<string, bigint>;
}

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

Save that as app/schemas/session.ts. defaults sets what a visitor starts with, so the cart is always there when your code reads it.

Define what the actions take#

app/schemas/cart.ts
export interface AddToCart {
  product_id: bigint;
  quantity: bigint;
}

export interface RemoveFromCart {
  product_id: bigint;
}

That goes in app/schemas/cart.ts. bigint crosses the wire as a wide integer, so it keeps its 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.

app/routes/cart/actions.ts
import { action, fail } from "@snapfire/fsr/host";
import type { ActionCtx } from "@snapfire/fsr/host";
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 session, already loaded. Assigning to it persists it. input is typed as AddToCart from the schema. 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.

app/routes/cart/page.loader.ts
import type { Ctx } from "@snapfire/fsr/host";

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.

Call it from the page#

app/routes/cart/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);
  return (
    <section>
      <p>{items} in your cart</p>
      <ul>
        {lines.map((line) => (
          <li key={String(line.id)}>
            {line.name} × {String(line.quantity)}
            <button onClick={() => void actions.cart.addToCart({ product_id: line.id, quantity: 1n })}>+</button>
            <button onClick={() => void actions.cart.addToCart({ product_id: line.id, quantity: -1n })}>-</button>
            <button onClick={() => void actions.cart.removeFromCart({ product_id: line.id })}>Remove</button>
          </li>
        ))}
      </ul>
    </section>
  );
}

actions.cart.addToCart is generated. If you pass it the wrong shape, the build reports it before anything reaches the browser. A successful call revalidates the route, so the loader runs again and the list shows the new quantities.

LiveThe cart page from this tutorial, with an Add button for each product

0 in your cart

    This cart is in your session on this site's server. Add something, reload the page and it's still there.

    Add a guard#

    A guard runs before anything else the action would do. If it refuses, no service is called and nothing is written. Add a checkout action to the same file, below the other two:

    app/routes/cart/actions.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. With an empty cart, the order service is never called.

    Next up: 100. Cache loaders and renders, where the pages that no longer change get answered before your code runs.