svimmer-store
v0.0.4
Published
Svimmer is a handle-based state model built on Immer.
Downloads
25
Readme
svimmer (beta)
Svimmer is a handle-based state model built on Immer.
You work with a single root store. You then focus into state with handles.
Core model
- State is accessed through handles.
- A handle points to a value path in the root state.
- Handles come in two permission levels:
- Reader: can read and observe.
- Writer: can read, observe, and mutate.
Permissions are inherited
writer.focus(...)returns a writer handle.reader.focus(...)returns a reader handle.
This keeps write permissions explicit and local.
Quick API map
createSvimmerStore(initial)
Creates the root writer handle.
read(accessor)
Read data from the current handle.
focus(selector)
Create a child handle from the current handle.
Returns null if the target does not exist.
Dynamic handles also support focus(selector), returning a dynamic child handle
that relocates with its parent while subscribing at the focused child path.
transact(mutator)
Mutate through Immer drafts on a writer handle.
set(value)
Replace the current handle value.
follow(locator) + locatorFor<T>()
Use dynamic handles that relocate when dependencies change.
subscribe(cb)
Observe value updates.
onDestroy(cb)
Observe lifecycle destruction when a handle path is removed.
Value handles and undefined
Svimmer uses value handles, even though path tracking is internal.
Because of this model, Svimmer does not distinguish between:
- a property that is
undefined - a property that does not exist
In practice, undefined means “deleted”.
Effects:
- Setting a field to
undefinedremoves it semantically. - Removed paths trigger
onDestroy. - Removed paths cannot be focused into.
set(undefined)is forbidden on value handles.
Why this rule exists:
- A child handle should not be able to delete itself from its parent via
set(undefined).
Recommendation:
- Prefer nullable fields (
null) over optional fields when you want an empty value that still exists.
Locators (dynamic handles)
Locators let you define reusable dynamic navigation recipes.
A locator is defined against a root type:
- You declare dependencies from that root.
- Dependencies may be root selectors or other locators created for the same root type.
- You get dependency handles in
locate(...). locate(...)returns the current selector (ornull).
Dependency handles are always read-only inside locate(...), even when the resulting
locator is followed from a writer handle:
- Selector dependencies are passed as focused reader handles.
- Locator dependencies are passed as dynamic reader handles.
This makes locators easy to define in external files and reuse across modules/components.
Locators are portable dynamic navigation recipes; they are not intended to be arbitrary
derived-value computations. Use them to choose where a handle points, then use
read(...), focus(...), or follow(...) on the returned handle for data access.
Example: external locator definition
// locators/company.ts
import { locatorFor } from "svimmer";
import type { CompanyDoc } from "./types";
const companyLocator = locatorFor<CompanyDoc>();
export const ceoLocator = companyLocator(
[x => x.ceoId],
(ceoIdRef) => {
const id = ceoIdRef.value();
return x => x.employees.get(id) ?? undefined;
},
);
export const betaProjectLocator = companyLocator(
[x => x.featureFlags.get("betaBilling")],
(flagRef) => {
const enabled = flagRef?.value() ?? false;
return enabled ? (x => x.projects[1]) : null;
},
);Example: locator dependencies
import { locatorFor } from "svimmer";
import type { LibraryDoc } from "./types";
const libLocator = locatorFor<LibraryDoc>();
export const activeProfileLoc = libLocator(
[x => x.activeProfileTag],
(activeProfileTagRef) => {
const tag = activeProfileTagRef?.value();
return tag ? (x => x.profiles[tag]) : null;
},
);
export const selectedNametableLoc = libLocator(
[activeProfileLoc],
(activeProfileRef) => {
const tag = activeProfileRef.current()?.read(x => x.selectedNametable);
return tag ? (x => x.nametables[tag]) : null;
},
);Example: using a locator
const store = createSvimmerStore(initialCompany);
const ceo = store.follow(ceoLocator);
ceo.read(x => x?.name); // dynamic read
const betaProject = store.follow(betaProjectLocator);
betaProject.current(); // handle or null
const lib = createSvimmerStore(initialLibrary);
const activeProfile = lib.follow(activeProfileLoc);
const selectedNametableTag = activeProfile.focus(x => x.selectedNametable);
const selectedNametable = lib.follow(selectedNametableLoc);
selectedNametable.current(); // selected nametable handle or nullnull from locate(...) means there is currently no target.
When dependencies change, the dynamic handle relocates automatically. Dynamic
focus(...) is useful for field-level handles under a dynamic target, such as
editing the selected nametable tag of the current active profile.
Recommended component architecture
- Keep values derived from handles inside components.
- Avoid passing raw derived values through many layers.
- Pass handles instead of snapshots when possible.
- Prefer passing the lowest common parent handle to a component.
- Avoid passing multiple sibling handles if one parent handle is enough.
This keeps component boundaries clean. It also avoids “privileged controller components”.
With root writer in App.svelte, permissions become simple:
- pass reader handles for read-only areas
- pass writer handles where mutation is allowed
Notes on value()
value() can be useful for:
- primitive reads
- creating snapshot copies
- serialization
Using value() as the default read path can produce stale/weird references.
Use read(...) as the normal access pattern.
