seitu
Version:
441 lines (327 loc) • 15.6 kB
Markdown
---
name: seitu-setup
description: >-
Adopt Seitu across an existing project: install it, find hand-rolled
reactive state and browser subscriptions (localStorage/sessionStorage/cookie
hooks, matchMedia, online status, scroll listeners, debounce/throttle
helpers, small global stores), and replace them with Seitu primitives
without losing persisted user data. Use when asked to set up Seitu, migrate
a codebase to Seitu, or replace existing state or storage code with Seitu.
metadata:
library: seitu
library_version: "1.3.1"
---
# Seitu setup and migration
Moves an existing app onto Seitu in reviewable steps. Read **seitu-overview**
first for the mental model. For the exact options of each primitive, read the
matching file in the **seitu** skill's `references/` folder before you write
the replacement.
The goal is less code with the same behavior. A replacement that changes what
a user sees, or drops data they already stored, is a regression.
## Workflow
1. **Detect the stack.** Do this before you change anything.
2. **Install** `seitu` and, when needed, a schema library.
3. **Inventory** the candidates with the searches below.
4. **Classify** each candidate as replace, keep or ask.
5. **Show the plan** to the user and wait for approval.
6. **Migrate one category at a time.** Verify after each category.
7. **Delete** the code and dependencies that nothing uses anymore.
Do not rewrite the whole project in one pass. A category is one row of the
replacement table, for example "all localStorage hooks".
## 1. Detect the stack
Read `package.json` and the lockfile, and record:
| Question | Where to look | Why it matters |
|----------|---------------|----------------|
| Package manager | `pnpm-lock.yaml`, `yarn.lock`, `bun.lock`, `package-lock.json` | Install command |
| Framework | `react`, `vue`, `solid-js`, `svelte` in dependencies | Binding import: `seitu/react`, `seitu/vue`, `seitu/solid`, `seitu/svelte` |
| Framework version | same | Seitu needs React >= 19, Vue >= 3.5, Solid >= 1.9, Svelte >= 5. Stop and tell the user if the project is older. |
| SSR | `next`, `nuxt`, `@tanstack/react-start`, `@sveltejs/kit`, `@solidjs/start`, `astro`, `remix`, `react-router` (framework mode) | `defaultMatches`, `createCookieValue` with `getServerCookies` |
| Schema library | `zod`, `valibot`, `arktype` | Storage primitives need a Standard Schema validator |
| Module format and Node | `"type"`, `engines` | Seitu is ESM-only and needs Node >= 22 |
A monorepo can use more than one framework. Pick the binding for each
package, not for the whole repository.
## 2. Install
```bash
pnpm add seitu # or npm i / yarn add / bun add
```
The framework bindings ship inside `seitu`, so there are no extra packages.
The storage primitives need a Standard Schema validator. Reuse the project's
validator. If there is none, ask the user before you add one. Zod is the
default suggestion.
## 3. Inventory
Search source files only. Exclude `node_modules`, build output and generated
files.
```bash
rg -n "localStorage|sessionStorage" --glob '!**/node_modules/**'
rg -n "document\.cookie|js-cookie|universal-cookie|react-cookie" --glob '!**/node_modules/**'
rg -n "matchMedia|useMediaQuery|prefers-color-scheme" --glob '!**/node_modules/**'
rg -n "navigator\.onLine|'online'|'offline'" --glob '!**/node_modules/**'
rg -n "addEventListener\(['\"]scroll|onScroll|scrollTop|scrollLeft" --glob '!**/node_modules/**'
rg -n "ResizeObserver|useElementSize|useResizeObserver|useMeasure" --glob '!**/node_modules/**'
rg -n "debounce|throttle" --glob '!**/node_modules/**'
rg -n "useSyncExternalStore|createContext|new EventTarget|EventEmitter|mitt\(" --glob '!**/node_modules/**'
rg -n "indexedDB|idb-keyval|from 'idb'|localforage|dexie" --glob '!**/node_modules/**'
rg -n "zustand|jotai|nanostores|valtio|@vueuse/core|svelte/store" --glob '!**/node_modules/**'
```
For each hit, record the file, what the code does, who reads the value and
who writes it. Group the hits into categories from the table below.
## 4. Classify
### Replace
| Existing code | Seitu primitive | Reference |
|---------------|-----------------|-----------|
| `useLocalStorage` / `useSessionStorage` hook, `useStorage` from VueUse, `writable` + `localStorage.setItem` in Svelte | `createWebStorageValue` (one key), `createWebStorage` (several related keys) | `create-web-storage-value.md`, `create-web-storage.md` |
| Cookie read in SSR and written in the browser (theme, language, consent) | `createCookieValue` with `getServerCookies` | `create-cookie-value.md` |
| `useMediaQuery`, `matchMedia(...).addEventListener('change', ...)` | `createMediaQuery` | `create-media-query.md` |
| `navigator.onLine` with `online`/`offline` listeners | `createIsOnline` | `create-is-online.md` |
| Scroll listener that computes "at top", "at bottom" or distance to an edge | `createScrollState` | `create-scroll-state.md` |
| `ResizeObserver` in an effect that stores width/height, `useElementSize`, `useResizeObserver` | `createElementSize` | `create-element-size.md` |
| Module-level variable plus a listener set, `EventEmitter` or `mitt` used as a store | `createStore` | `create-store.md` |
| Small global store (zustand, nanostores, jotai atom, Svelte `writable`) with no middleware | `createStore`, or `createSchemaStore` when the value is validated | `create-store.md`, `create-schema-store.md` |
| React context that only shares one value and its setter | `createStore` at module scope + `useSubscription` | `react.md` |
| `useMemo` / `computed` / `derived` over values that are now Seitu handles | `createComputed` | `create-computed.md` |
| `lodash.debounce`, `lodash.throttle` or a hand-written timer whose result is shown in the UI | `createDebouncedFn`, `createThrottledFn` | `create-debounced-fn.md`, `create-throttled-fn.md` |
| Debounced copy of another reactive value (for example search input) | `createDebounced`, `createThrottled` | `create-debounced.md`, `create-throttled.md` |
| `idb-keyval`, `localforage` or raw `indexedDB` used as a key/value store | `createIndexedDb` + `createIndexedDbStorage` | `create-indexed-db.md`, `create-indexed-db-storage.md` |
| Raw `indexedDB` object stores with indexes and queries | `createIndexedDb` + `createIndexedDbTable` | `create-indexed-db-table.md` |
| Custom browser subscription (visibility, resize, clipboard, geolocation) | `createSubscription` + `createReadableSubscription` | `create-subscription.md`, `create-readable-subscription.md` |
### Keep
Do not replace these. List them in the plan with the reason.
- **Server state**: TanStack Query, SWR, Apollo, tRPC and RTK Query caches. Seitu does not fetch, cache or revalidate.
- **Form state**: React Hook Form, TanStack Form, Formik, VeeValidate.
- **Router state**: search params, route params.
- **Local UI state**: `useState` / `ref` / `$state` that only one component reads, such as an open menu or an input draft.
- **Stores with middleware or devtools** in use (Redux, zustand `persist` with migrations, `immer`, time travel). Replacing them changes behavior, so ask first.
- **`HttpOnly` cookies**: JavaScript cannot read or write them.
- **Debounce or throttle inside a library** that you do not own.
### Ask
Ask the user before you replace:
- A dependency that other packages in a monorepo also use.
- A store with more than about ten consumers.
- Any storage key whose stored shape you cannot prove from the code.
- A change that removes a public export of a package.
## 5. Show the plan
Before you edit files, give the user a short plan:
```md
Replace
- localStorage hooks (4 files): src/hooks/use-local-storage.ts -> createWebStorageValue
- matchMedia (2 files): src/hooks/use-media-query.ts -> createMediaQuery
Keep
- TanStack Query cache: server state
- src/features/editor/store.ts: zustand with persist migrations, ask before changing
New dependency
- seitu (and zod, not installed yet)
```
Wait for approval. Then migrate one category at a time.
## 6. Migrate
### Rules for every replacement
- **Create shared handles once, at module scope.** Put them next to the feature that owns them, for example `src/stores/theme.ts`. Do not create a handle inside a component body. When a handle depends on props, pass a factory to `useSubscription` with `deps`.
- **Keep the storage key.** Use the same `key` as the old code, so returning users keep their data.
- **Write a schema that accepts the stored data.** Seitu parses the stored string with `JSON.parse`. A value that is not valid JSON is read as a plain string. If validation fails, `get()` returns `defaultValue` and the stored value is overwritten on the next `set()`.
- **Match the old default.** Use the same fallback value the old code used.
- **Keep the SSR output.** If the old code rendered a value on the server, the new code must render the same value. Use `defaultMatches` for media queries. Use `createCookieValue` with `getServerCookies` for values the server must show.
- **Return new objects from updaters.** `set()` does not notify when the new value is the same reference.
- **Import the binding for the file's framework.** Do not import `seitu/react` in Vue, Solid or Svelte code.
### Stored data compatibility
Check the old write path for each key before you choose the schema:
| Old write | Stored string | Seitu reads | Schema |
|-----------|---------------|-------------|--------|
| `setItem('theme', 'dark')` | `dark` | `'dark'` | `z.enum(['light', 'dark'])` |
| `setItem('theme', JSON.stringify('dark'))` | `"dark"` | `'dark'` | same |
| `setItem('count', String(5))` | `5` | `5` (number) | `z.number()`, not `z.string()` |
| `setItem('id', '123')` where the id is a string | `123` | `123` (number) | `z.coerce.string()` or a union |
| `setItem('user', JSON.stringify(user))` | JSON object | object | object schema with the real shape |
When the stored shape changed between app versions, use `onValidationError`
to repair the value, or `repairValueObjectWithDefault` from `seitu/utils` for
objects that gained fields. Do not silently reset user data that you can
repair.
### Before and after
**localStorage hook (React)**
Before:
```tsx
function useTheme() {
const [theme, setTheme] = useState(() => localStorage.getItem('theme') ?? 'light')
useEffect(() => {
localStorage.setItem('theme', theme)
}, [theme])
return [theme, setTheme] as const
}
```
After:
```ts
// src/stores/theme.ts
import { createWebStorageValue } from 'seitu/web'
import * as z from 'zod'
export const theme = createWebStorageValue({
type: 'localStorage',
key: 'theme',
schema: z.enum(['light', 'dark']),
defaultValue: 'light',
})
```
```tsx
import { useSubscription } from 'seitu/react'
import { theme } from '~/stores/theme'
const value = useSubscription(theme)
theme.set('dark')
```
The old hook read `localStorage` during render, so it failed on the server and
did not sync across tabs. The new handle does both.
**Media query hook**
Before:
```ts
function useIsDesktop() {
const [matches, setMatches] = useState(false)
useEffect(() => {
const mql = window.matchMedia('(min-width: 1024px)')
const onChange = () => setMatches(mql.matches)
onChange()
mql.addEventListener('change', onChange)
return () => mql.removeEventListener('change', onChange)
}, [])
return matches
}
```
After:
```ts
export const isDesktop = createMediaQuery({
query: '(min-width: 1024px)',
defaultMatches: false, // the old hook rendered false on the server
})
```
Consumers change from `useIsDesktop()` to `useSubscription(isDesktop)`.
**Global store with a listener set**
Before:
```ts
let count = 0
const listeners = new Set<() => void>()
export const counter = {
get: () => count,
inc: () => {
count++
listeners.forEach((l) => l())
},
subscribe: (l: () => void) => {
listeners.add(l)
return () => listeners.delete(l)
},
}
```
After:
```ts
export const counter = createStore(0)
counter.set((n) => n + 1)
```
**Vue: VueUse `useStorage`**
Before:
```ts
const token = useStorage('token', null)
```
After:
```ts
// stores/token.ts
export const token = createWebStorageValue({
type: 'localStorage',
key: 'token',
schema: z.string().nullable(),
defaultValue: null,
})
```
```ts
const value = useSubscription(token) // readonly ShallowRef, write with token.set()
```
Code that wrote `token.value = x` now calls `token.set(x)`.
**Svelte: `writable` persisted by hand**
Before:
```ts
export const lang = writable(localStorage.getItem('lang') ?? 'en')
lang.subscribe((v) => localStorage.setItem('lang', v))
```
After:
```ts
export const lang = createWebStorageValue({
type: 'localStorage',
key: 'lang',
schema: z.enum(['en', 'de']),
defaultValue: 'en',
})
```
```svelte
<script lang="ts">
const value = useSubscription(lang)
</script>
<p>{$value}</p>
```
`$lang = 'de'` becomes `lang.set('de')`.
### Changing call sites
Keep a thin wrapper only when many files call the old API and the user wants a
small diff:
```ts
/** @deprecated Use useSubscription(theme) directly. */
export const useTheme = () => useSubscription(theme)
```
Otherwise update every call site and delete the old hook in the same step.
## 7. Verify and clean up
After each category:
1. Run the project's type check, tests and lint.
2. Build the app when it uses SSR, and check for hydration warnings.
3. Test the behavior in the browser when you can: a value survives a reload, two tabs stay in sync, a media query updates on resize.
4. Search again for the old pattern, so no call site is left behind.
At the end:
- Delete hooks, utils and types that nothing imports anymore.
- Remove dependencies that nothing imports anymore (`use-local-storage-state`, `js-cookie`, `lodash.debounce`, `idb-keyval`, `zustand` and similar). Check every package of a monorepo first.
- Report to the user what was replaced, what was kept and why, and any storage key whose schema you had to guess.
## Common Mistakes
### [CRITICAL] Renaming the storage key
Wrong:
```ts
createWebStorageValue({ type: 'localStorage', key: 'app:theme', ... }) // old key was 'theme'
```
Correct:
```ts
createWebStorageValue({ type: 'localStorage', key: 'theme', ... })
```
A new key starts every returning user from `defaultValue`.
### [CRITICAL] Schema stricter than the stored data
Wrong:
```ts
schema: z.string() // old code stored String(5), which Seitu reads as 5
```
Correct:
```ts
schema: z.number()
```
A failed validation returns `defaultValue` and the next `set()` overwrites the
stored value.
### [HIGH] Creating a handle inside a component
Wrong:
```tsx
function Toolbar() {
const isDesktop = useSubscription(createMediaQuery({ query: '(min-width: 1024px)' }))
}
```
Correct:
```tsx
const isDesktop = createMediaQuery({ query: '(min-width: 1024px)' })
function Toolbar() {
const matches = useSubscription(isDesktop)
}
```
### [HIGH] Replacing server state or form state
Seitu handles are not a fetch cache or a form library. Keep TanStack Query,
SWR, React Hook Form and similar tools.
### [HIGH] Migrating everything in one pass
Big rewrites hide regressions. Migrate one category, verify it, then continue.
### [MEDIUM] Keeping a `typeof window` guard around creation
Wrong:
```ts
const theme = typeof window !== 'undefined' ? createWebStorageValue({ ... }) : null
```
Correct:
```ts
const theme = createWebStorageValue({ ... })
```
Seitu web primitives return their defaults on the server.
## See also
- **seitu-overview**: module map, mental model, decision tree
- **seitu**: per-primitive options in `references/`
- Docs: https://seitu.letstri.dev/docs