Fragno
Features

Client-side State Management

Managing state in Fragno client-side fragments

Edit on GitHub

An important feature of Fragno is the ability to define client-side components as part of the Fragment. Under the hood, Fragno uses Nanostores to manage state. These reactive primitives integrate seamlessly with many popular frontend frameworks, such as React, Svelte, Vue, and vanilla JavaScript. Helper functions are provided to automatically create reactive stores for each API route. These are inspired by the popular TanStack Query library.

The ClientBuilder class

The ClientBuilder class is the starting point for creating client-side Fragments. It is instantiated with the library definition, the public config, and the routes. It is used as part of your Fragment's export.

src/index.ts
import { type FragnoPublicClientConfig } from "@fragno-dev/core";
import { createClientBuilder } from "@fragno-dev/core/client";

export function createMyFragmentClient(fragnoConfig: FragnoPublicClientConfig = {}) {
  const builder = createClientBuilder(myFragmentDefinition, fragnoConfig, routes);

  return {
    useTodos: builder.createHook("/todos"),
  };
}

The object returned from the createMyFragmentClient function is the client-side Fragment's entry point. It contains the hooks and mutators for the routes, as well as any other values that you want to expose to the user on the client-side. The object is passed through the framework-specific useFragno helper function, which returns the hooks and mutators as reactive stores for the framework.

Reading from GET routes with createHook

The createHook method is used to create a read-only query hook for a route. It takes the route's path and returns a function that can be called by the user.

The createHook method takes an optional object with the following properties:

Prop

Type

Fragment User Usage

Your users will use this hook by calling the function with an object containing path and query fields. These fields are based on the route's path and query parameters. The function returns an object with the following properties:

  • data: The data returned from the route. Based on the route's output schema.
  • loading: A boolean indicating if the route is loading.
  • error: An error object if the route fails. Based on the route's error codes, if set.

The parameters passed to the hook by the user are fully reactive, meaning that when the user updates the parameters, the hook will automatically re-run and update the data.

Streaming Responses

As covered in the Route Definition section, Fragno supports streaming responses. When a route returns a streaming response, the output schema is required to be an array. When a hook starts fetching in the streaming case, the loading state will be true until the first item is received. The array in data will then contain the first item, but will be updated reactively as more items come in.

Mutating data with createMutator

The createMutator method is used to create a mutator for a route. It takes the route's method and path and returns a function that can be called by the user.

src/index.ts
// ... in your createMyFragmentClient function ...
return {
  useTodos: builder.createHook("/todos"),
  useCreateTodo: builder.createMutator("POST", "/todos"),
  useUpdateTodo: builder.createMutator("PUT", "/todos/:id"),
  useDeleteTodo: builder.createMutator("DELETE", "/todos/:id"),
};

Invalidating Data

The createMutator method takes an optional third parameter that is a function called when the mutation has succeeded. It receives the invalidate function, which can be used to invalidate other routes.

When no onInvalidate function is provided, the following default implementation is used:

const defaultOnInvalidate = (invalidate, params) => invalidate("GET", route.path, params);

In the example above, when the useCreateTodo mutator is called, it will invalidate the useTodos hook.

Invalidation only happens on exactly the same route/path

In this example, updating or deleting a todo would not invalidate the useTodos hook. Therefore, we'd have to specify a custom onInvalidate function.

Default behaviour might be removed in the future

This default behavior might be removed in the future as we cannot automatically determine which routes should be invalidated.

Fragment User Usage

In addition to the data, loading, and error properties, the hook will also return a mutate function that can be used to mutate the data.

const { data, loading, error, mutate } = useTodos();

// ... in some onClick handler ...
await mutate({ body: { text: "New todo" } });

The resulting data is also returned from the mutate function, but generally it should be read from the data property instead.

The loading state will be undefined until a mutation has started.

Streaming Responses

Like with the createHook method, when a route returns a streaming response, the loading state will be true until the first item is received. The array in data will then contain the first item, but will be updated reactively as more items come in.

Advanced Use-Cases

Not everything can be covered by the createHook and createMutator methods. There are cases where you want more fine-grained control over state management.

In Fragno, Fragment authors can use Nanostores directly to create reactive stores and derived values.

Derived Data

Consider the following example:

src/index.ts
import { computed } from "nanostores";

// ... in your createMyFragmentClient function ...
const chatStream = builder.createMutator("POST", "/chat/stream");

const aggregatedMessage = computed(chatStream.mutatorStore, ({ data }) => {
  return (data ?? [])
    .filter((item) => item.type === "response.output_text.delta")
    .map((item) => item.delta)
    .join("");
});

return {
  useChatStream: chatStream,
  useAggregatedMessage: builder.createStore(aggregatedMessage),
};

Here we're creating a derived store that aggregates the delta messages from the chat stream. When data is populated in the chatStream store, the aggregatedMessage store will be updated reactively. In this example, we must call builder.createStore to ensure the aggregated message can be transformed into a reactive store for the framework the user is using.

Mutate must be called

Since the aggregated message is derived from the chat stream, the message will not be populated until the mutate function on the useChatStream hook is called.

Like computed, atom and effect are also supported. Please refer to the Nanostores documentation for more information.

Arbitrary Values

We can clean up the example above by returning a single hook from the client-side Fragment factory.

src/index.ts
// ... in your createMyFragmentClient function ...

function sendMessage(message: string) {
  chatStream.mutatorStore.mutate({
    body: {
      messages: [{ type: "chat", id: crypto.randomUUID(), role: "user", content: message }],
    },
  });
}

return {
  useSendMessage: builder.createStore({
    response: aggregatedMessage,
    responseLoading: computed(chatStream.mutatorStore, ({ loading }) => loading),
    sendMessage,
  }),
  fragmentVersion: "1.2.0",
};

In this example, we've hidden the 'raw' chat stream from the user. Instead, we've exposed a single useSendMessage hook that allows the user to send a message and get the response, while handling the aggregation in the background. Instead of giving the user a mutate function, we've provided them with a more descriptive sendMessage function.

We can return any other values or functions from the createMyFragmentClient function.

`createStore` nesting

If an object is passed to createStore, all fields on this object will be made reactive. In the case of a function, nothing will happen to it. Deeply nested keys will not be considered.