Skip to content

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)

// Bootstrap runs once at app startup
async function bootstrapApp() {
  await PostsStore.query("posts:fetch");
}
bootstrapApp();
// Bootstrap runs once at app startup
async function bootstrapApp(): Promise<void> {
  await PostsStore.query<Post[]>("posts:fetch");
}
bootstrapApp();

Option 2: URL-based routing with URLStore

import { createURLStore } from "cami";

const router = createURLStore();

// Data loads when navigating to this route
router.registerRoute("/posts", {
  onEnter: async (): Promise<void> => {
    await PostsStore.query<Post[]>("posts:fetch");
  },
});

router.initialize();
import { createURLStore } from "cami";

const router = createURLStore();

// Data loads when navigating to this route
router.registerRoute("/posts", {
  onEnter: async () => {
    await PostsStore.query("posts:fetch");
  },
});

router.initialize();

Option 3: User-initiated (button click)

class BlogPostsElement extends ReactiveElement {
  template() {
    const { posts } = PostsStore.getState();

    return html`
      <button @click=${() => PostsStore.query("posts:fetch")}>
        Load Posts
      </button>
      ${this.renderPosts(posts)}
    `;
  }
}
class BlogPostsElement extends ReactiveElement {
  template(): ReturnType<typeof html> {
    const { posts } = PostsStore.getState();

    return html`
      <button @click=${() => PostsStore.query<Post[]>("posts:fetch")}>
        Load Posts
      </button>
      ${this.renderPosts(posts)}
    `;
  }
}

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

  1. Model request state explicitly: Use a shape like { status, data, error } for each async operation.

  2. Use actions for state updates: Query/mutation callbacks should dispatch actions, not mutate state directly.

  3. Use staleTime wisely: Set appropriate stale times to avoid unnecessary refetches.

  4. Invalidate queries after mutations: Use invalidateQueries() in onSuccess to refetch related data.

  5. Handle all states: Always render pending, error, and success states.

See the ObservableStore API for full documentation.