Skip to content

Build The First Store

Most web apps begin with the same task: fetch data from an API and show it on the page.

In our example, that first task is the home page with a list of events, a search field, and a category filter.

The first store will hold the filters, the loaded events, and the loading flag.

import { action, effect, mountable, onMount, signal } from '@nano_kit/store'
import { type BoardEvent, type EventCategory, fetchEvents } from '#src/services/events'
export const $q = signal('')
export const $category = signal<EventCategory | null>(null)
export const $events = mountable(signal<BoardEvent[]>([]))
export const $loading = signal(false)
export const loadEvents = action(async (
q: string,
category: EventCategory | null
) => {
$loading(true)
const page = await fetchEvents({
q,
category
})
$events(page.events)
$loading(false)
})
onMount($events, () => effect(() => {
void loadEvents($q(), $category())
}))

This store module exports the state that the home page needs and one action for loading events.

signal creates a reactive value. $q, $category, and $loading are plain signals that hold the current filters and loading status.

$events is different because it is wrapped in mountable. A mountable signal has a lifecycle. It can run setup code when something starts listening to it, and cleanup code when nobody needs it anymore. The read/write API stays the same, but the signal can now participate in onMount.

onMount($events, callback) runs the callback when $events becomes active. In React, that happens when a component calls useSignal for $events. If the component unmounts and nothing else uses $events, the cleanup returned by the callback runs.

Here the callback creates an effect. The effect reads $q and $category, so it reruns when either filter changes. On each run it passes the current filter values to loadEvents(). The first request starts when the event list is actually rendered.

action wraps loadEvents so signal reads inside the action are untracked. In this case the filters are passed as arguments, which keeps the dependencies of the effect visible at the call site. As a good practice, wrap business functions like this in action when they read or write signals and may be called from effects. It prevents accidental tracking bugs as the function grows. The action sets loading state, calls the API, and writes the new events.

Let’s render that state with one React component:

import { useSignal } from '@nano_kit/react'
import { $events, $loading, $q } from '#src/stores/events'
export function Home() {
const q = useSignal($q)
const events = useSignal($events)
const loading = useSignal($loading)
return (
<section>
<input
value={q}
onChange={event => $q(event.currentTarget.value)}
placeholder='Search events'
/>
{events.map(event => (
<article key={event.id}>
<h2>{event.title}</h2>
<p>{event.description}</p>
</article>
))}
{loading && <p>Loading...</p>}
</section>
)
}

At this stage the app has one component and one store. It can fetch events, react to filter changes, and render loading state.