Client-side State Management
Managing state in Fragno client-side fragments
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.
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.
// ... 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:
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.
// ... 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.