Asynchronous Server State Management
Cami provides a powerful async state management system through store-level queries and mutations. Queries fetch data and cache it, while mutations update server data and can perform optimistic updates.
Example backend
Requests in this guide use https://cami-api.exe.xyz, a public mock API deployed for Cami examples. Its data may change as readers create, update, and delete records. Use your own application endpoint in production.
Queries
Queries are used to fetch data asynchronously with built-in caching, refetching, and lifecycle callbacks.
Setting Up a Query
First, define your state shape, actions to update it, and then the query:
const { store, html, ReactiveElement } = cami;
// 1. Define state shape
const PostsStore = store({
name: "PostsStore",
state: {
posts: {
status: "idle", // "idle" | "pending" | "success" | "error"
data: [],
error: null,
},
},
});
// 2. Define actions to update state
PostsStore.defineAction("posts:setPending", ({ state }) => {
state.posts.status = "pending";
state.posts.error = null;
});
PostsStore.defineAction("posts:setSuccess", ({ state, payload }) => {
state.posts.status = "success";
state.posts.data = payload;
});
PostsStore.defineAction("posts:setError", ({ state, payload }) => {
state.posts.status = "error";
state.posts.error = payload.message || String(payload);
});
// 3. Define the query
PostsStore.defineQuery("posts:fetch", {
queryKey: ["posts"],
queryFn: () => fetch("https://cami-api.exe.xyz/posts?_limit=5").then(r => r.json()),
staleTime: 1000 * 60 * 5, // Data is fresh for 5 minutes
onFetch: ({ dispatch }) => {
dispatch("posts:setPending");
},
onSuccess: ({ data, dispatch }) => {
dispatch("posts:setSuccess", data);
},
onError: ({ error, dispatch }) => {
dispatch("posts:setError", error);
},
});
import { html, ReactiveElement, store } from 'cami';
type RequestStatus = "idle" | "pending" | "success" | "error";
interface Post {
id: number;
title: string;
body: string;
}
interface PostsState {
posts: {
status: RequestStatus;
data: Post[];
error: string | null;
};
}
// 1. Define state shape
const PostsStore = store<PostsState>({
name: "PostsStore",
state: {
posts: {
status: "idle", // "idle" | "pending" | "success" | "error"
data: [],
error: null,
},
},
});
// 2. Define actions to update state
PostsStore.defineAction("posts:setPending", ({ state }) => {
state.posts.status = "pending";
state.posts.error = null;
});
PostsStore.defineAction("posts:setSuccess", ({ state, payload }) => {
state.posts.status = "success";
state.posts.data = payload as Post[];
});
PostsStore.defineAction("posts:setError", ({ state, payload }) => {
state.posts.status = "error";
state.posts.error = payload instanceof Error ? payload.message : String(payload);
});
// 3. Define the query
PostsStore.defineQuery<void, Post[]>("posts:fetch", {
queryKey: ["posts"],
queryFn: async (): Promise<Post[]> => {
const response = await fetch("https://cami-api.exe.xyz/posts?_limit=5");
return await response.json() as Post[];
},
staleTime: 1000 * 60 * 5, // Data is fresh for 5 minutes
onFetch: ({ dispatch }) => {
dispatch("posts:setPending");
},
onSuccess: ({ data, dispatch }) => {
dispatch("posts:setSuccess", data);
},
onError: ({ error, dispatch }) => {
dispatch("posts:setError", error);
},
});
Triggering Queries
Queries should be triggered at the application level, not in component lifecycle methods like onConnect(). This ensures data is fetched once when needed, not every time a component mounts.
Option 1: Bootstrap function (recommended for initial data)
Option 2: URL-based routing with URLStore
Option 3: User-initiated (button click)
Rendering Query State
Components are pure renderers—they read state and display it:
class BlogPostsElement extends ReactiveElement {
template() {
const { posts } = PostsStore.getState();
if (posts.status === "pending") {
return html`<div>Loading...</div>`;
}
if (posts.status === "error") {
return html`<div>Error: ${posts.error}</div>`;
}
if (posts.status === "success") {
return html`
<ul>
${posts.data.map(post => html`
<li>
<h3>${post.title}</h3>
<p>${post.body}</p>
</li>
`)}
</ul>
`;
}
return html`<div>No posts loaded</div>`;
}
}
customElements.define('blog-posts-element', BlogPostsElement);
class BlogPostsElement extends ReactiveElement {
template() {
const { posts } = PostsStore.getState();
if (posts.status === "pending") {
return html`<div>Loading...</div>`;
}
if (posts.status === "error") {
return html`<div>Error: ${posts.error}</div>`;
}
if (posts.status === "success") {
return html`
<ul>
${posts.data.map(post => html`
<li>
<h3>${post.title}</h3>
<p>${post.body}</p>
</li>
`)}
</ul>
`;
}
return html`<div>No posts loaded</div>`;
}
}
customElements.define('blog-posts-element', BlogPostsElement);
Refetching Data
Trigger a refetch via user interaction (button click):
class BlogPostsElement extends ReactiveElement {
template() {
const { posts } = PostsStore.getState();
return html`
<button @click=${() => PostsStore.query("posts:fetch")}>Refetch Posts</button>
${this.renderPosts(posts)}
`;
}
renderPosts(posts) {
if (posts.status === "pending") {
return html`<div>Loading...</div>`;
}
if (posts.status === "error") {
return html`<div>Error: ${posts.error}</div>`;
}
return html`
<ul>
${posts.data.map(post => html`
<li><h3>${post.title}</h3></li>
`)}
</ul>
`;
}
}
class BlogPostsElement extends ReactiveElement {
template() {
const { posts } = PostsStore.getState();
return html`
<button @click=${() => PostsStore.query("posts:fetch")}>Refetch Posts</button>
${this.renderPosts(posts)}
`;
}
renderPosts(posts) {
if (posts.status === "pending") {
return html`<div>Loading...</div>`;
}
if (posts.status === "error") {
return html`<div>Error: ${posts.error}</div>`;
}
return html`
<ul>
${posts.data.map(post => html`
<li><h3>${post.title}</h3></li>
`)}
</ul>
`;
}
}
Query Configuration Options
| Option | Type | Default | Description |
|---|---|---|---|
queryKey |
string \| string[] \| (args) => string[] |
Required | Unique cache key |
queryFn |
(args) => Promise |
Required | Function to fetch data |
staleTime |
number |
0 |
Time in ms before data is considered stale |
gcTime |
number |
300000 |
Time in ms before unused cache is garbage collected |
retry |
number |
1 |
Number of retry attempts |
retryDelay |
number \| (attempt) => number |
1000 |
Delay between retries |
refetchOnWindowFocus |
boolean |
false |
Refetch when window gains focus |
refetchOnReconnect |
boolean |
true |
Refetch when network reconnects |
refetchInterval |
number \| null |
null |
Polling interval in ms |
Mutations
Mutations are used to modify server-side data. They support optimistic updates and rollback on error.
Setting Up a Mutation
// Add mutation state to the store
const PostsStore = store({
name: "PostsStore",
state: {
posts: { status: "idle", data: [], error: null },
addPost: { status: "idle", error: null },
},
});
// Action to handle add post status
PostsStore.defineAction("addPost:setPending", ({ state }) => {
state.addPost.status = "pending";
state.addPost.error = null;
});
PostsStore.defineAction("addPost:setSuccess", ({ state }) => {
state.addPost.status = "success";
});
PostsStore.defineAction("addPost:setError", ({ state, payload }) => {
state.addPost.status = "error";
state.addPost.error = payload.message || String(payload);
});
PostsStore.defineAction("addPost:reset", ({ state }) => {
state.addPost = { status: "idle", error: null };
});
// Define the mutation
PostsStore.defineMutation("posts:create", {
mutationFn: (newPost) => {
return fetch("https://cami-api.exe.xyz/posts", {
method: "POST",
body: JSON.stringify(newPost),
headers: { "Content-Type": "application/json" },
}).then(r => r.json());
},
onMutate: ({ dispatch }) => {
dispatch("addPost:setPending");
},
onSuccess: ({ dispatch, invalidateQueries }) => {
dispatch("addPost:setSuccess");
// Refetch posts to get the new one
invalidateQueries({ queryKey: ["posts"] });
},
onError: ({ error, dispatch }) => {
dispatch("addPost:setError", error);
},
});
type AddPostStatus = "idle" | "pending" | "success" | "error";
interface NewPost {
title: string;
body: string;
}
interface MutationState {
posts: { status: RequestStatus; data: Post[]; error: string | null };
addPost: { status: AddPostStatus; error: string | null };
}
// Add mutation state to the store
const PostsStore = store<MutationState>({
name: "PostsStore",
state: {
posts: { status: "idle", data: [], error: null },
addPost: { status: "idle", error: null },
},
});
// Action to handle add post status
PostsStore.defineAction("addPost:setPending", ({ state }) => {
state.addPost.status = "pending";
state.addPost.error = null;
});
PostsStore.defineAction("addPost:setSuccess", ({ state }) => {
state.addPost.status = "success";
});
PostsStore.defineAction("addPost:setError", ({ state, payload }) => {
state.addPost.status = "error";
state.addPost.error = payload instanceof Error ? payload.message : String(payload);
});
PostsStore.defineAction("addPost:reset", ({ state }) => {
state.addPost = { status: "idle", error: null };
});
// Define the mutation
PostsStore.defineMutation<NewPost, Post>("posts:create", {
mutationFn: async (newPost: NewPost): Promise<Post> => {
const response = await fetch("https://cami-api.exe.xyz/posts", {
method: "POST",
body: JSON.stringify(newPost),
headers: { "Content-Type": "application/json" },
});
return await response.json() as Post;
},
onMutate: ({ dispatch }) => {
dispatch("addPost:setPending");
},
onSuccess: ({ dispatch, invalidateQueries }) => {
dispatch("addPost:setSuccess");
// Refetch posts to get the new one
invalidateQueries({ queryKey: ["posts"] });
},
onError: ({ error, dispatch }) => {
dispatch("addPost:setError", error);
},
});
Using Mutations in Components
class AddPostForm extends ReactiveElement {
template() {
const { addPost } = PostsStore.getState();
if (addPost.status === "pending") {
return html`<div>Adding post...</div>`;
}
if (addPost.status === "error") {
return html`
<div>Error: ${addPost.error}</div>
<button @click=${() => PostsStore.dispatch("addPost:reset")}>Try Again</button>
`;
}
if (addPost.status === "success") {
return html`
<div>Post added successfully!</div>
<button @click=${() => PostsStore.dispatch("addPost:reset")}>Add Another</button>
`;
}
return html`
<form @submit=${this.handleSubmit}>
<input type="text" name="title" placeholder="Post Title" required />
<textarea name="body" placeholder="Post Body" required></textarea>
<button type="submit">Add Post</button>
</form>
`;
}
handleSubmit(e) {
e.preventDefault();
const form = e.target;
const title = form.elements.title.value;
const body = form.elements.body.value;
PostsStore.mutate("posts:create", { title, body });
}
}
customElements.define('add-post-form', AddPostForm);
class AddPostForm extends ReactiveElement {
template(): ReturnType<typeof html> {
const { addPost } = PostsStore.getState();
if (addPost.status === "pending") {
return html`<div>Adding post...</div>`;
}
if (addPost.status === "error") {
return html`
<div>Error: ${addPost.error}</div>
<button @click=${() => PostsStore.dispatch("addPost:reset")}>Try Again</button>
`;
}
if (addPost.status === "success") {
return html`
<div>Post added successfully!</div>
<button @click=${() => PostsStore.dispatch("addPost:reset")}>Add Another</button>
`;
}
return html`
<form @submit=${this.handleSubmit}>
<input type="text" name="title" placeholder="Post Title" required />
<textarea name="body" placeholder="Post Body" required></textarea>
<button type="submit">Add Post</button>
</form>
`;
}
handleSubmit(event: SubmitEvent): void {
event.preventDefault();
const form = event.currentTarget as HTMLFormElement;
const data = new FormData(form);
const title = String(data.get("title") ?? "");
const body = String(data.get("body") ?? "");
void PostsStore.mutate<Post>("posts:create", { title, body } satisfies NewPost);
}
}
customElements.define('add-post-form', AddPostForm);
Optimistic Updates with Rollback
For a smoother UX, update the UI immediately and rollback on error:
PostsStore.defineMutation("posts:delete", {
mutationFn: (postId) => {
return fetch(`https://cami-api.exe.xyz/posts/${postId}`, {
method: "DELETE",
});
},
onMutate: ({ state, payload, dispatch }) => {
// Optimistically remove the post
// Note: previousState is automatically captured before onMutate runs
dispatch("posts:setSuccess", state.posts.data.filter(p => p.id !== payload));
},
onError: ({ error, dispatch, previousState }) => {
// Rollback using previousState (automatically captured before mutation)
dispatch("posts:setSuccess", previousState.posts.data);
console.error("Delete failed:", error);
},
onSuccess: ({ invalidateQueries }) => {
// Optionally refetch to ensure consistency
invalidateQueries({ queryKey: ["posts"] });
},
});
PostsStore.defineMutation<number, Response>("posts:delete", {
mutationFn: (postId: number): Promise<Response> => {
return fetch(`https://cami-api.exe.xyz/posts/${postId}`, {
method: "DELETE",
});
},
onMutate: ({ state, payload, dispatch }) => {
// Optimistically remove the post
// Note: previousState is automatically captured before onMutate runs
dispatch("posts:setSuccess", state.posts.data.filter((post: Post) => post.id !== payload));
},
onError: ({ error, dispatch, previousState }) => {
// Rollback using previousState (automatically captured before mutation)
dispatch("posts:setSuccess", (previousState as PostsState).posts.data);
console.error("Delete failed:", error);
},
onSuccess: ({ invalidateQueries }) => {
// Optionally refetch to ensure consistency
invalidateQueries({ queryKey: ["posts"] });
},
});
Mutation Configuration Options
| Option | Type | Description |
|---|---|---|
mutationFn |
(args) => Promise |
Function to perform the mutation |
onMutate |
(context) => any |
Called before mutation (for optimistic updates) |
onSuccess |
(context) => void |
Called on success |
onError |
(context) => void |
Called on error |
onSettled |
(context) => void |
Called after success or error |
Complete Example
Here's a full working example with queries and mutations:
<blog-component></blog-component>
<script src="https://unpkg.com/cami@0.4/build/cami.cdn.js"></script>
<script type="module" src="./island.js"></script>
Island source
const { html, ReactiveElement, store } = cami
const BlogStore = store({
name: 'BlogStore',
state: {
posts: { status: 'idle', data: [], error: null },
addPost: { status: 'idle', error: null },
},
});
BlogStore.defineAction('posts:setPending', ({ state }) => {
state.posts.status = 'pending';
state.posts.error = null;
});
BlogStore.defineAction('posts:setSuccess', ({ state, payload }) => {
state.posts = { status: 'success', data: payload, error: null };
});
BlogStore.defineAction('posts:setError', ({ state, payload }) => {
state.posts.status = 'error';
state.posts.error = payload instanceof Error ? payload.message : String(payload);
});
BlogStore.defineAction('posts:addOptimistic', ({ state, payload }) => {
const post = payload;
state.posts.data.push({ ...post, id: Date.now() });
});
BlogStore.defineAction('addPost:setStatus', ({ state, payload }) => {
state.addPost = payload;
});
BlogStore.defineQuery('posts:fetch', {
queryKey: ['posts'],
queryFn: async () => {
const response = await fetch('https://cami-api.exe.xyz/posts?_limit=5');
if (!response.ok)
throw new Error(`Posts request failed: ${response.status}`);
return await response.json();
},
staleTime: 5 * 60_000,
onFetch: ({ dispatch }) => dispatch('posts:setPending'),
onSuccess: ({ data, dispatch }) => dispatch('posts:setSuccess', data),
onError: ({ error, dispatch }) => dispatch('posts:setError', error),
});
BlogStore.defineMutation('posts:create', {
mutationFn: async (newPost) => {
const response = await fetch('https://cami-api.exe.xyz/posts', {
method: 'POST',
body: JSON.stringify(newPost),
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok)
throw new Error(`Create request failed: ${response.status}`);
return await response.json();
},
onMutate: ({ payload, dispatch }) => {
dispatch('addPost:setStatus', { status: 'pending', error: null });
dispatch('posts:addOptimistic', payload);
},
onError: ({ error, dispatch, previousState }) => {
dispatch('posts:setSuccess', previousState.posts.data);
dispatch('addPost:setStatus', { status: 'error', error: error.message });
},
onSuccess: ({ dispatch }) => dispatch('addPost:setStatus', { status: 'success', error: null }),
onSettled: ({ invalidateQueries }) => invalidateQueries({ queryKey: ['posts'] }),
});
void BlogStore.query('posts:fetch');
class BlogComponent extends ReactiveElement {
handleAddPost() {
void BlogStore.mutate('posts:create', {
title: 'New Post, Made Optimistically',
body: 'This post appears before the mock API responds.',
userId: 1,
});
}
template() {
const { posts, addPost } = BlogStore.getState();
if (addPost.status === 'pending')
return html `<p>Adding post…</p>`;
if (posts.status === 'pending')
return html `<p>Loading…</p>`;
if (posts.error)
return html `<p role="alert">${posts.error}</p>`;
return html `
<button @click=${() => this.handleAddPost()}>Add Post</button>
<ul>
${posts.data.slice().reverse().map((post) => html `
<li><h2>${post.title}</h2><p>${post.body}</p></li>
`)}
</ul>
`;
}
}
customElements.define('blog-component', BlogComponent);
import { html, ReactiveElement, store } from 'cami'
type RequestStatus = 'idle' | 'pending' | 'success' | 'error'
interface Post {
id: number
title: string
body: string
userId: number
}
interface NewPost {
title: string
body: string
userId: number
}
interface BlogState {
posts: { status: RequestStatus; data: Post[]; error: string | null }
addPost: { status: RequestStatus; error: string | null }
}
const BlogStore = store<BlogState>({
name: 'BlogStore',
state: {
posts: { status: 'idle', data: [], error: null },
addPost: { status: 'idle', error: null },
},
})
BlogStore.defineAction('posts:setPending', ({ state }) => {
state.posts.status = 'pending'
state.posts.error = null
})
BlogStore.defineAction('posts:setSuccess', ({ state, payload }) => {
state.posts = { status: 'success', data: payload as Post[], error: null }
})
BlogStore.defineAction('posts:setError', ({ state, payload }) => {
state.posts.status = 'error'
state.posts.error = payload instanceof Error ? payload.message : String(payload)
})
BlogStore.defineAction('posts:addOptimistic', ({ state, payload }) => {
const post = payload as NewPost
state.posts.data.push({ ...post, id: Date.now() })
})
BlogStore.defineAction('addPost:setStatus', ({ state, payload }) => {
state.addPost = payload as BlogState['addPost']
})
BlogStore.defineQuery<void, Post[]>('posts:fetch', {
queryKey: ['posts'],
queryFn: async (): Promise<Post[]> => {
const response = await fetch('https://cami-api.exe.xyz/posts?_limit=5')
if (!response.ok) throw new Error(`Posts request failed: ${response.status}`)
return await response.json() as Post[]
},
staleTime: 5 * 60_000,
onFetch: ({ dispatch }) => dispatch('posts:setPending'),
onSuccess: ({ data, dispatch }) => dispatch('posts:setSuccess', data),
onError: ({ error, dispatch }) => dispatch('posts:setError', error),
})
BlogStore.defineMutation<NewPost, Post>('posts:create', {
mutationFn: async (newPost: NewPost): Promise<Post> => {
const response = await fetch('https://cami-api.exe.xyz/posts', {
method: 'POST',
body: JSON.stringify(newPost),
headers: { 'Content-Type': 'application/json' },
})
if (!response.ok) throw new Error(`Create request failed: ${response.status}`)
return await response.json() as Post
},
onMutate: ({ payload, dispatch }) => {
dispatch('addPost:setStatus', { status: 'pending', error: null })
dispatch('posts:addOptimistic', payload)
},
onError: ({ error, dispatch, previousState }) => {
dispatch('posts:setSuccess', (previousState as BlogState).posts.data)
dispatch('addPost:setStatus', { status: 'error', error: error.message })
},
onSuccess: ({ dispatch }) => dispatch('addPost:setStatus', { status: 'success', error: null }),
onSettled: ({ invalidateQueries }) => invalidateQueries({ queryKey: ['posts'] }),
})
void BlogStore.query<Post[]>('posts:fetch')
class BlogComponent extends ReactiveElement {
handleAddPost(): void {
void BlogStore.mutate<Post>('posts:create', {
title: 'New Post, Made Optimistically',
body: 'This post appears before the mock API responds.',
userId: 1,
} satisfies NewPost)
}
template(): ReturnType<typeof html> {
const { posts, addPost } = BlogStore.getState()
if (addPost.status === 'pending') return html`<p>Adding post…</p>`
if (posts.status === 'pending') return html`<p>Loading…</p>`
if (posts.error) return html`<p role="alert">${posts.error}</p>`
return html`
<button @click=${() => this.handleAddPost()}>Add Post</button>
<ul>
${posts.data.slice().reverse().map((post: Post) => html`
<li><h2>${post.title}</h2><p>${post.body}</p></li>
`)}
</ul>
`
}
}
declare global {
interface HTMLElementTagNameMap {
'blog-component': BlogComponent
}
}
customElements.define('blog-component', BlogComponent)
Best Practices
-
Model request state explicitly: Use a shape like
{ status, data, error }for each async operation. -
Use actions for state updates: Query/mutation callbacks should dispatch actions, not mutate state directly.
-
Use
staleTimewisely: Set appropriate stale times to avoid unnecessary refetches. -
Invalidate queries after mutations: Use
invalidateQueries()inonSuccessto refetch related data. -
Handle all states: Always render pending, error, and success states.
See the ObservableStore API for full documentation.