A lightweight, type-safe reactive state store for React and TypeScript.
react-rock provides a simple store for managing structured application data with schema validation, CRUD operations, querying, metadata, subscriptions, and React integration.
The core store is independent of React. React components connect to the store through useStore(), which is built on React's useSyncExternalStore.
- Type-safe state management
- Schema-based validation with
xanv - Reactive updates
- React integration
- External subscriptions
- Scoped subscriptions
- CRUD operations
- Bulk creation
- Query operators
- Metadata management
- Row ordering
- Automatic row identifiers
- Automatic row version identifiers
- No React dependency in the core store
npm install react-rock xanvCreate a store with a schema:
import createStore, { useStore } from "react-rock";
import { xv } from "xanv";
const users = createStore({
name: xv.string(),
email: xv.string(),
age: xv.number(),
});Use the store inside a React component:
function Users() {
const store = useStore(users);
const rows = store.rows;
return (
<div>
{rows.map((user) => (
<div key={user.rid}>
{user.name} — {user.email}
</div>
))}
</div>
);
}Create data:
users.create({
data: {
name: "John",
email: "john@example.com",
age: 25,
},
});When the store changes, subscribed React components automatically re-render.
A Store contains a collection of rows and provides operations for reading, creating, updating, deleting, and reordering those rows.
const users = createStore({
name: xv.string(),
email: xv.string(),
age: xv.number(),
});The schema defines the shape of every row.
Each row automatically contains two internal fields:
{
rid: number;
vid: number;
}rid is the row identifier.
vid is the row version identifier and changes whenever the row is updated.
A row therefore has a shape similar to:
{
name: string;
email: string;
age: number;
rid: number;
vid: number;
}useStore() connects a Store to React.
import { useStore } from "react-rock";
function UserList() {
const store = useStore(users);
return (
<ul>
{store.rows.map((user) => (
<li key={user.rid}>{user.name}</li>
))}
</ul>
);
}Internally, useStore() uses React's useSyncExternalStore.
The store itself does not depend on React, which means it can also be used outside React components.
Subscribe to store changes from any JavaScript or TypeScript code.
const unsubscribe = users.subscribe(() => {
console.log("Users changed");
});Stop listening:
unsubscribe();Subscriptions are not limited to React components.
For example:
users.subscribe(() => {
console.log(users.rows);
});This can be used by services, event handlers, application logic, or other external consumers.
react-rock supports subscription scopes through scope.
function User({ id }: { id: number }) {
const store = useStore(users, `user:${id}`);
const user = store.findById(id);
return <div>{user?.name}</div>;
}A store operation can notify a specific scope:
users.update({
where: {
rid: id,
},
data: {
name: "Naxrul",
},
scope: `user:${id}`,
});Only subscribers using that scope are notified.
This allows larger stores to be consumed by independently reactive components.
A global subscription can still be used:
users.subscribe(() => {
// Global store update
});Create a single row.
const user = users.create({
data: {
name: "John",
email: "john@example.com",
age: 25,
},
});The created row is returned:
console.log(user.rid);
console.log(user.vid);Values are passed through the corresponding schema before being stored.
Create multiple rows at once.
const created = users.createMany({
data: [
{
name: "John",
email: "john@example.com",
age: 25,
},
{
name: "Jane",
email: "jane@example.com",
age: 28,
},
],
});The result is an array containing the created rows.
Notifications can be disabled:
users.createMany({
data: [
{
name: "John",
email: "john@example.com",
age: 25,
},
],
notify: false,
});Access all rows:
const rows = users.rows;The rows are returned in their current order.
Find rows using a where condition.
const result = users.find({
where: {
name: "John",
},
});Multiple conditions are supported:
const result = users.find({
where: {
name: "John",
age: 25,
},
});Conditions are evaluated together.
react-rock supports several query operators.
users.find({
where: {
name: {
contain: "oh",
},
},
});Useful for substring matching.
users.find({
where: {
name: {
startWith: "Jo",
},
},
});users.find({
where: {
name: {
endWith: "hn",
},
},
});users.find({
where: {
age: {
equalWith: 25,
},
},
});users.find({
where: {
age: {
notEqualWith: 25,
},
},
});Greater than:
users.find({
where: {
age: {
gt: 18,
},
},
});Less than:
users.find({
where: {
age: {
lt: 30,
},
},
});Greater than or equal to:
users.find({
where: {
age: {
gte: 18,
},
},
});Less than or equal to:
users.find({
where: {
age: {
lte: 30,
},
},
});Returns the first matching row or null.
const user = users.findOne({
where: {
email: "john@example.com",
},
});Find a row using its rid.
const user = users.findById(1);The result is either the row or undefined.
if (user) {
console.log(user.name);
}Get the current position of a row.
const index = users.getIndex(user.rid);If the row does not exist, -1 is returned.
Update one or more matching rows.
users.update({
where: {
rid: 1,
},
data: {
name: "Naxrul",
},
});Multiple rows can be updated:
users.update({
where: {
age: {
lt: 18,
},
},
data: {
age: 18,
},
});rid and vid cannot be modified through data.
When a row is updated, its vid is automatically regenerated.
Delete rows matching a condition.
const count = users.delete({
where: {
rid: 1,
},
});The return value is the number of deleted rows.
console.log(count);Multiple rows can be deleted:
users.delete({
where: {
age: {
lt: 18,
},
},
});Move a row from one index to another.
users.move({
fromIndex: 3,
toIndex: 0,
});The method returns:
booleantrue means the row was moved successfully.
false means one or both indexes were invalid.
Stores can optionally contain metadata in addition to rows.
const users = createStore(
{
name: xv.string(),
email: xv.string(),
},
{
page: xv.number().default(1),
loading: xv.boolean().default(false),
search: xv.string().default(""),
},
);Metadata is separate from row data.
It is useful for information such as:
- Pagination state
- Loading state
- Search state
- UI state
- Filters
- Application-specific store state
const page = users.getMeta("page");The returned value is strongly typed according to the metadata schema.
users.setMeta("page", 2);Another example:
users.setMeta("loading", true);Metadata values are validated using their corresponding schema.
users.deleteMeta("search");Remove all metadata:
users.clearMetas();Store operations normally notify subscribers automatically.
For operations that support notification control:
users.create({
data: {
name: "John",
email: "john@example.com",
age: 25,
},
notify: false,
});The following operations support notify:
createcreateManyupdatedeletemove
This can be useful when performing multiple mutations and notifying subscribers only when the complete operation is finished.
react-rock derives row types directly from the schema.
For example:
const users = createStore({
name: xv.string(),
email: xv.string(),
age: xv.number(),
});TypeScript understands:
const user = users.create({
data: {
name: "John",
email: "john@example.com",
age: 25,
},
});Incorrect values are rejected:
users.create({
data: {
name: "John",
email: "john@example.com",
age: "25",
},
});The same schema-derived types are used by:
create()createMany()find()findOne()findById()update()delete()getMeta()setMeta()
Optional fields can be defined using xanv.
const users = createStore({
name: xv.string(),
email: xv.string(),
age: xv.number().optional(),
});The resulting row type contains an optional age.
{
name: string;
email: string;
age?: number | undefined;
rid: number;
vid: number;
}import createStore, { useStore } from "react-rock";
import { xv } from "xanv";
const users = createStore(
{
name: xv.string(),
email: xv.string(),
age: xv.number(),
},
{
loading: xv.boolean().default(false),
search: xv.string().default(""),
},
);
function UserList() {
const store = useStore(users);
const search = store.getMeta("search");
const rows = store.find({
where: {
name: {
contain: search,
},
},
});
return (
<div>
{rows.map((user) => (
<div key={user.rid}>
<strong>{user.name}</strong>
<span>{user.email}</span>
</div>
))}
</div>
);
}
function addUser() {
users.create({
data: {
name: "John",
email: "john@example.com",
age: 25,
},
});
}
function updateUser(id: number) {
users.update({
where: {
rid: id,
},
data: {
name: "Updated User",
},
});
}
function removeUser(id: number) {
users.delete({
where: {
rid: id,
},
});
}createStore<RS, MS>(
rowSchema: RS,
metaSchema?: MS,
): Store<RS, MS>Creates a new store.
class Store<
RS extends RowSchema,
MS extends MetaSchema | undefined = undefined,
>rows
metascreate()
createMany()
update()
delete()
find()
findOne()
findById()
getIndex()
move()getMeta()
setMeta()
deleteMeta()
clearMetas()subscribe()
getSnapshot()| API | Purpose |
|---|---|
createStore() |
Create a store |
useStore() |
Connect a store to React |
rows |
Get all rows |
metas |
Get metadata map |
create() |
Create one row |
createMany() |
Create multiple rows |
find() |
Query rows |
findOne() |
Get first matching row |
findById() |
Find a row by rid |
getIndex() |
Get a row's index |
update() |
Update matching rows |
delete() |
Delete matching rows |
move() |
Reorder rows |
getMeta() |
Read metadata |
setMeta() |
Set metadata |
deleteMeta() |
Delete metadata |
clearMetas() |
Clear metadata |
subscribe() |
Subscribe to changes |
getSnapshot() |
Get the current subscription version |
The Store does not use React hooks or React APIs.
React integration is provided separately through useStore().
This allows the same store instance to be used by both React components and ordinary application code.
The schema is the source of truth for row and metadata types.
Runtime values are parsed through their corresponding xanv schema.
The React integration uses useSyncExternalStore, making the store compatible with React's external store subscription model.
scope allows different parts of an application to subscribe to different update scopes without creating separate stores.
The API is intentionally centered around a small set of operations:
create
read
update
delete
subscribe
with querying, metadata, and ordering built around the same store model.
Add the project's license information here.