Skip to content

CRUD Feature Store

A complete recipe for a CRUD feature store with entities, loading states, and error handling.

Full example

ts
import { createStore, createEntityAdapter, http, retryable, optimistic } from '@ngstato/core'

// ── Types ──
type Student = { id: number; name: string; level: string; active: boolean }
type CreateStudent = Omit<Student, 'id'>

// ── Entity Adapter ──
const adapter = createEntityAdapter<Student>({
  selectId: (s) => s.id,
  sortComparer: (a, b) => a.name.localeCompare(b.name)
})

// ── Store ──
export const studentsStore = createStore({
  ...adapter.getInitialState(),
  loading:    false,
  error:      null as string | null,
  selectedId: null as number | null,

  // ── Selectors (memoized) ──
  selectors: {
    allStudents:    (state) => adapter.selectAll(state),
    totalStudents:  (state) => adapter.selectTotal(state),
    selectedStudent: (state) => {
      if (!state.selectedId) return null
      return adapter.selectById(state, state.selectedId) ?? null
    },
    activeStudents: (state) => adapter.selectAll(state).filter(s => s.active)
  },

  // ── Computed ──
  computed: {
    hasError:  (state) => state.error !== null,
    isEmpty:   (state) => adapter.selectTotal(state) === 0
  },

  // ── Actions ──
  actions: {
    // Load all — with retry
    loadAll: retryable(async (state) => {
      state.loading = true
      state.error = null
      try {
        const students = await http.get<Student[]>('/students')
        adapter.setAll(state, students)
      } catch (e) {
        state.error = (e as Error).message
        throw e   // rethrow so retryable can retry
      } finally {
        state.loading = false
      }
    }, { attempts: 3, backoff: 'exponential', delay: 1000 }),

    // Create
    async createStudent(state, data: CreateStudent) {
      state.loading = true
      try {
        const created = await http.post<Student>('/students', data)
        adapter.addOne(state, created)
      } catch (e) {
        state.error = (e as Error).message
      } finally {
        state.loading = false
      }
    },

    // Update
    async updateStudent(state, id: number, changes: Partial<Student>) {
      try {
        const updated = await http.patch<Student>(`/students/${id}`, changes)
        adapter.updateOne(state, { id, changes: updated })
      } catch (e) {
        state.error = (e as Error).message
      }
    },

    // Delete — optimistic with rollback
    deleteStudent: optimistic(
      (state, id: number) => {
        adapter.removeOne(state, id)
        if (state.selectedId === id) state.selectedId = null
      },
      async (state, id: number) => {
        await http.delete(`/students/${id}`)
      }
    ),

    // Selection
    selectStudent(state, id: number | null) {
      state.selectedId = id
    },

    // Clear error
    clearError(state) {
      state.error = null
    }
  },

  // ── Hooks ──
  hooks: {
    onInit:  (store) => store.loadAll(),
    onError: (err, name) => console.error(`[StudentsStore] ${name}:`, err.message)
  }
})

Angular component

ts
@Component({
  selector: 'app-students',
  template: `
    @if (store.loading()) {
      <div class="loading-bar"></div>
    }

    @if (store.hasError()) {
      <div class="error-banner">
        {{ store.error() }}
        <button (click)="store.clearError()">Dismiss</button>
      </div>
    }

    <header>
      <h1>Students ({{ store.totalStudents() }})</h1>
      <button (click)="openCreateDialog()">+ New Student</button>
    </header>

    @if (store.isEmpty()) {
      <p>No students yet.</p>
    } @else {
      <ul>
        @for (student of store.allStudents(); track student.id) {
          <li [class.selected]="student.id === store.selectedId()"
              (click)="store.selectStudent(student.id)">
            <span>{{ student.name }} — {{ student.level }}</span>
            <button (click)="store.deleteStudent(student.id); $event.stopPropagation()">
              Delete
            </button>
          </li>
        }
      </ul>
    }

    @if (store.selectedStudent()) {
      <aside>
        <h2>{{ store.selectedStudent()!.name }}</h2>
        <p>Level: {{ store.selectedStudent()!.level }}</p>
      </aside>
    }
  `
})
export class StudentsComponent {
  store = injectStore(StudentsStore)
}

Action pattern summary

ActionHTTPAdapterError handling
loadAllGETsetAllretryable()
createStudentPOSTaddOnetry/catch
updateStudentPATCHupdateOnetry/catch
deleteStudentDELETEremoveOneoptimistic() + auto-rollback

Why this works

  • Entities keep state normalized — no duplicate data
  • Selectors are memoized — no re-computation on unrelated changes
  • Optimistic delete gives instant feedback, rollback on failure
  • Retryable load handles flaky networks automatically
  • Hooks auto-load on init, log errors consistently

Released under the MIT License.