Skip to content

Todo List (Server State)

This shows how to fetch and update data on a server. Loading, error, and success states are handled automatically.

The example uses shared tutorial data from https://cami-api.exe.xyz.

Live islandRuns locally in this page

Page shell

<article>
  <todo-list-server></todo-list-server>
</article>

<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 TodoStore = store({
    name: 'TodoStore',
    state: {
        todos: { status: 'idle', data: [], error: null },
        mutation: { status: 'idle', error: null },
    },
});
TodoStore.defineAction('todos:setPending', ({ state }) => {
    state.todos.status = 'pending';
    state.todos.error = null;
});
TodoStore.defineAction('todos:setSuccess', ({ state, payload }) => {
    state.todos = { status: 'success', data: payload, error: null };
});
TodoStore.defineAction('todos:setError', ({ state, payload }) => {
    state.todos.status = 'error';
    state.todos.error = payload instanceof Error ? payload.message : String(payload);
});
TodoStore.defineAction('mutation:set', ({ state, payload }) => {
    state.mutation = payload;
});
TodoStore.defineQuery('todos:fetch', {
    queryKey: ['todos'],
    queryFn: async () => {
        const response = await fetch('https://cami-api.exe.xyz/todos?_limit=5');
        if (!response.ok)
            throw new Error(`Todos request failed: ${response.status}`);
        return await response.json();
    },
    staleTime: 5 * 60_000,
    onFetch: ({ dispatch }) => dispatch('todos:setPending'),
    onSuccess: ({ data, dispatch }) => dispatch('todos:setSuccess', data),
    onError: ({ error, dispatch }) => dispatch('todos:setError', error),
});
TodoStore.defineMutation('todos:create', {
    mutationFn: async (newTodo) => {
        const response = await fetch('https://cami-api.exe.xyz/todos', {
            method: 'POST',
            body: JSON.stringify(newTodo),
            headers: { 'Content-Type': 'application/json' },
        });
        if (!response.ok)
            throw new Error(`Create request failed: ${response.status}`);
        return await response.json();
    },
    onMutate: ({ dispatch }) => dispatch('mutation:set', { status: 'pending', error: null }),
    onError: ({ error, dispatch }) => dispatch('mutation:set', { status: 'error', error: error.message }),
    onSuccess: ({ dispatch }) => dispatch('mutation:set', { status: 'success', error: null }),
    onSettled: ({ invalidateQueries }) => invalidateQueries({ queryKey: ['todos'] }),
});
TodoStore.defineMutation('todos:delete', {
    mutationFn: async (todo) => {
        const response = await fetch(`https://cami-api.exe.xyz/todos/${todo.id}`, { method: 'DELETE' });
        if (!response.ok)
            throw new Error(`Delete request failed: ${response.status}`);
    },
    onMutate: ({ dispatch }) => dispatch('mutation:set', { status: 'pending', error: null }),
    onError: ({ error, dispatch }) => dispatch('mutation:set', { status: 'error', error: error.message }),
    onSuccess: ({ dispatch }) => dispatch('mutation:set', { status: 'success', error: null }),
    onSettled: ({ invalidateQueries }) => invalidateQueries({ queryKey: ['todos'] }),
});
void TodoStore.query('todos:fetch');
class TodoListElement extends ReactiveElement {
    handleAddTodo() {
        const input = this.querySelector('.newTodo');
        const title = input?.value.trim() ?? '';
        if (!input || !title)
            return;
        void TodoStore.mutate('todos:create', { title, completed: false });
        input.value = '';
    }
    handleDeleteTodo(todo) {
        void TodoStore.mutate('todos:delete', todo);
    }
    template() {
        const { todos, mutation } = TodoStore.getState();
        if (todos.status === 'pending')
            return html `<p>Loading…</p>`;
        if (todos.error)
            return html `<p role="alert">${todos.error}</p>`;
        return html `
      <input class="newTodo" placeholder="Add a todo">
      <button @click=${() => this.handleAddTodo()} ?disabled=${mutation.status === 'pending'}>Add</button>
      ${mutation.error ? html `<p role="alert">${mutation.error}</p>` : ''}
      <ul>${todos.data.slice().reverse().map((todo) => html `
        <li>${todo.title} <button @click=${() => this.handleDeleteTodo(todo)}>Delete</button></li>
      `)}</ul>
    `;
    }
}
customElements.define('todo-list-server', TodoListElement);
import { html, ReactiveElement, store } from 'cami'

type RequestStatus = 'idle' | 'pending' | 'success' | 'error'

interface Todo {
  id: number
  title: string
  completed: boolean
}

type NewTodo = Omit<Todo, 'id'>

interface TodoState {
  todos: { status: RequestStatus; data: Todo[]; error: string | null }
  mutation: { status: RequestStatus; error: string | null }
}

const TodoStore = store<TodoState>({
  name: 'TodoStore',
  state: {
    todos: { status: 'idle', data: [], error: null },
    mutation: { status: 'idle', error: null },
  },
})

TodoStore.defineAction('todos:setPending', ({ state }) => {
  state.todos.status = 'pending'
  state.todos.error = null
})

TodoStore.defineAction('todos:setSuccess', ({ state, payload }) => {
  state.todos = { status: 'success', data: payload as Todo[], error: null }
})

TodoStore.defineAction('todos:setError', ({ state, payload }) => {
  state.todos.status = 'error'
  state.todos.error = payload instanceof Error ? payload.message : String(payload)
})

TodoStore.defineAction('mutation:set', ({ state, payload }) => {
  state.mutation = payload as TodoState['mutation']
})

TodoStore.defineQuery<void, Todo[]>('todos:fetch', {
  queryKey: ['todos'],
  queryFn: async (): Promise<Todo[]> => {
    const response = await fetch('https://cami-api.exe.xyz/todos?_limit=5')
    if (!response.ok) throw new Error(`Todos request failed: ${response.status}`)
    return await response.json() as Todo[]
  },
  staleTime: 5 * 60_000,
  onFetch: ({ dispatch }) => dispatch('todos:setPending'),
  onSuccess: ({ data, dispatch }) => dispatch('todos:setSuccess', data),
  onError: ({ error, dispatch }) => dispatch('todos:setError', error),
})

TodoStore.defineMutation<NewTodo, Todo>('todos:create', {
  mutationFn: async (newTodo: NewTodo): Promise<Todo> => {
    const response = await fetch('https://cami-api.exe.xyz/todos', {
      method: 'POST',
      body: JSON.stringify(newTodo),
      headers: { 'Content-Type': 'application/json' },
    })
    if (!response.ok) throw new Error(`Create request failed: ${response.status}`)
    return await response.json() as Todo
  },
  onMutate: ({ dispatch }) => dispatch('mutation:set', { status: 'pending', error: null }),
  onError: ({ error, dispatch }) => dispatch('mutation:set', { status: 'error', error: error.message }),
  onSuccess: ({ dispatch }) => dispatch('mutation:set', { status: 'success', error: null }),
  onSettled: ({ invalidateQueries }) => invalidateQueries({ queryKey: ['todos'] }),
})

TodoStore.defineMutation<Todo, void>('todos:delete', {
  mutationFn: async (todo: Todo): Promise<void> => {
    const response = await fetch(`https://cami-api.exe.xyz/todos/${todo.id}`, { method: 'DELETE' })
    if (!response.ok) throw new Error(`Delete request failed: ${response.status}`)
  },
  onMutate: ({ dispatch }) => dispatch('mutation:set', { status: 'pending', error: null }),
  onError: ({ error, dispatch }) => dispatch('mutation:set', { status: 'error', error: error.message }),
  onSuccess: ({ dispatch }) => dispatch('mutation:set', { status: 'success', error: null }),
  onSettled: ({ invalidateQueries }) => invalidateQueries({ queryKey: ['todos'] }),
})

void TodoStore.query<Todo[]>('todos:fetch')

class TodoListElement extends ReactiveElement {
  handleAddTodo(): void {
    const input = this.querySelector<HTMLInputElement>('.newTodo')
    const title = input?.value.trim() ?? ''
    if (!input || !title) return
    void TodoStore.mutate<Todo>('todos:create', { title, completed: false } satisfies NewTodo)
    input.value = ''
  }

  handleDeleteTodo(todo: Todo): void {
    void TodoStore.mutate<void>('todos:delete', todo)
  }

  template(): ReturnType<typeof html> {
    const { todos, mutation } = TodoStore.getState()
    if (todos.status === 'pending') return html`<p>Loading…</p>`
    if (todos.error) return html`<p role="alert">${todos.error}</p>`

    return html`
      <input class="newTodo" placeholder="Add a todo">
      <button @click=${() => this.handleAddTodo()} ?disabled=${mutation.status === 'pending'}>Add</button>
      ${mutation.error ? html`<p role="alert">${mutation.error}</p>` : ''}
      <ul>${todos.data.slice().reverse().map((todo: Todo) => html`
        <li>${todo.title} <button @click=${() => this.handleDeleteTodo(todo)}>Delete</button></li>
      `)}</ul>
    `
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'todo-list-server': TodoListElement
  }
}

customElements.define('todo-list-server', TodoListElement)