Onek
⚡️ 1.7KB full-featured state management inspired by MobX and Solid, batteries included ⚡️
Onek is a simple but powerful state management library for React based on solid foundation of functional reactive
data structures from MobX and Solid.js, providing everything needed for managing state in complex React applications,
all in less than 2KB package.
Features
- 🚀 Reactive observable and computed values - just like MobX, Solid.js or Preact Signals
- 👁 Transparency - no data glitches guaranteed
- 🔄 Transactional updates - no unexpected side-effects
- 🙈 Laziness - nothing happens until you need a value
- 🤓 Built-in shallow equality for easily optimizing re-renders
- 🤔 Not opinionated about structure of your models
- 🎱 No need for selectors or wrapping your components into lambdas
- 💯 100% tests coverage with complex cases
- ⭐️ Written in 100% TypeScript
- 📦 ...and all in less than 2KB package
Table of contents
Introduction
Observable values
Define observable
value. The first value in returned array is getter function, the second is setter - the same convention like useState
from React:
import { observable } from "onek";
const [greeting, setGreeting] = observable("hello!");
greeting() === "hello!";
setGreeting("hola!");
greeting() === "hola!";
setGreeting((oldGreeting) => oldGreeting + "!!!");
greeting() === "hola!!!!";
Extra: equality check argument
The second argument to observable
might be equality check function (or true
for built-in shallowEquals
implementation):
import { shallowEquals } from "onek";
const [number, setNumber] = observable(1, true);
const [number, setNumber] = observable(1, shallowEquals);
setNumber(1);
Computed values
Define computed
value. Computed value is like useMemo
in React - it's cached and return the cached value afterwards. All accessed observable
or other computed
values are automatically tracked, there is no need to specify dependency list. Changes to these tracked values automatically invalidate the cached value, which is recalculated on next access to the computed
:
import { computed } from "onek";
const loudGreeting = computed(() => greeting().toUpperCase());
loudGreeting() === "HOLA!!!!";
setGreeting("hi!");
loudGreeting() === "HI!";
Extra: equality check argument
The second argument to computed
is also equality check function (or true
for built-in implementation):
const [numbers, setNumbers] = observable([1, 2, 3, 4]);
const sortedNumbers = computed(() => numbers().slice().sort(), true);
const result = sortedNumbers();
console.log(result);
setNumbers([4, 3, 2, 1]);
sortedNumbers() === result;
Using with React
Using observable
and computed
in React components is as simple as:
import { observable, computed, useObserver } from "onek";
const [greeting, setGreeting] = observable("hello!");
const loudGreeting = computed(() => greeting().toUpperCase());
const LoudGreeting = () => {
useObserver();
return <p>{loudGreeting()}</p>;
};
const GreetingInput = () => {
useObserver();
return (
<input
type="text"
onChange={(e) => setGreeting(e.target.value)}
value={greeting()}
/>
);
};
root.render(
<>
<GreetingInput />
<LoudGreeting />
</>
);
useObserver
hook has no arguments and doesn't return anything :) The only rule - it has to be called before first use of any observable or computed value, and follow "rule of hooks" as well.
Under the hood of this black hook magic
Onek internally patches React's createElement
with very low-overhead addition that allows to track observable and computed values accessed inside the component. This is safe and environmental-friendly, it should not hurt anyone of conflict with other patching library.
Actions and transactions
Actions automatically batch updates to observable values, and also make access to observable getters untracked - so if your action is called inside component's render function or inside reaction, it won't make it re-render on change of these accessed values.
Important note: by default all changes to observable
values are batched until the end of current microtask. In order to make reaction run synchronous on changes, please read Changing reaction runner
const [x, setX] = observable(1);
const [y, setY] = observable(2);
const updateValues = action((value) => {
const xValue = x();
setX(0);
setY(xValue + value);
});
updateValues(100);
Transaction is the same, except it's executed immediately and doesn't make values access untracked:
import { tx } from "onek";
tx(() => {
setX(100);
setY(200);
});
To get the same behaviour as action
use utx
(Untracked transaction) instead.
Reactions
Reaction is a way to react to observable or computed changes without involving React. It's the same as autorun
function from MobX:
import { reaction } from "onek";
const disposer = reaction(() => {
console.log("Greeting is " + greeting());
});
setGreeting("Привет!");
disposer();
setGreeting("Hello!");
disposer.run();
Return value of reaction body might be reaction destructor - a function that is called before each reaction run and on disposer
call:
const [topic, setTopic] = observable("something");
const disposer = reaction(() => {
const currentTopic = topic();
subscribeToTopic(currentTopic, callback);
return () => {
unsubscribeFromTopic(currentTopic, callback);
};
});
setTopic("different");
Examples?
Simple counter
Simple counter - Actions and models
import { observable, action, useObserver } from "onek";
const makeCounter = (initial) => {
const [count, setCount] = observable(initial);
const inc = action(() => setCount((count) => count + 1));
const dec = action(() => setCount((count) => count - 1));
const reset = action(() => setCount(initial));
return { count, inc, dec, reset };
};
const Counter = ({ counter }) => {
const { counter, inc, dec, reset } = counter;
useObserver();
return (
<>
<button onClick={inc}>+</button>
<button onClick={dec}>-</button>
<button onClick={reset}>Reset</button>
Count: {counter()}
</>
);
};
const counter = makeCounter(0);
root.render(<Counter counter={counter} />);
Counter list
Counter list with stats - Model composition and computed data
import { observable, computed, action, useObserver } from "onek";
import { makeCounter, Counter } from "./Counter";
const makeCountersList = () => {
const [counters, setCounters] = observable([]);
const countersCount = computed(() => counters().length);
const countersSum = computed(() =>
counters().reduce((sum, counter) => sum + counter.count(), 0)
);
const addCounter = action(() => {
const counter = makeCounter(0);
setCounters((counters) => [...counters, counter]);
});
const removeCounter = action((counter) => {
setCounters((counters) =>
counters.filter((_counter) => _counter !== counter)
);
});
const resetAll = action(() => {
counters().forEach((counter) => counter.reset());
});
return {
counters,
countersCount,
countersSum,
addCounter,
removeCounter,
resetAll,
};
};
const CounterStats = ({ count, sum }) => {
useObserver();
return (
<>
<p>Total count: {count()}</p>
<p>Total sum: {sum()}</p>
</>
);
};
const CountersList = ({ model }) => {
useObserver();
return (
<div>
<CounterStats count={model.countersCount} sum={model.countersSum} />
<button onClick={model.addCounter}>Add</button>
<button onClick={model.resetAll}>Reset all</button>
{model.counters().map((counter) => (
<div>
<Counter model={counter} />
<button onClick={() => model.removeCounter(counter)}>Remove</button>
</div>
))}
</div>
);
};
const countersList = makeCountersList();
root.render(<CountersList model={countersList} />);
Todo List
Todo List - Complex multi-component app
import { action, observable, computed, useObserver } from "onek";
let id = 0;
export const makeTodo = (todoText) => {
const [text, setText] = observable(todoText);
const [done, setDone] = observable(false);
const toggleDone = action(() => {
setDone((done) => !done);
});
return {
id: id++,
text,
done,
setText,
toggleDone,
};
};
export const makeTodoList = () => {
const [text, setText] = observable("");
const [todos, setTodos] = observable([], true);
const [filter, setFilter] = observable("ALL");
const doneTodos = computed(() => {
return todos().filter((todo) => todo.done());
});
const undoneTodos = computed(() => {
return todos().filter((todo) => !todo.done());
});
const visibleTodos = computed(() => {
switch (filter()) {
case "ALL":
return todos();
case "DONE":
return doneTodos();
case "UNDONE":
return undoneTodos();
}
}, true);
const addTodo = action(() => {
const todo = makeTodo(text());
setTodos((todos) => [...todos, todo]);
setText("");
});
const removeTodo = action((todo) => {
setTodos((todos) => todos.filter((_todo) => _todo !== todo));
});
const clearDone = action((todo) => {
setTodos(undoneTodos());
});
return {
text,
setText,
todos,
filter,
visibleTodos,
setFilter,
addTodo,
removeTodo,
clearDone,
};
};
const FILTER_OPTIONS = [
{ name: "All", value: "ALL" },
{ name: "Done", value: "DONE" },
{ name: "Undone", value: "UNDONE" },
];
const NewTodoInput = ({ model }) => {
const { text, setText, addTodo } = model;
useObserver();
return (
<div>
<input onChange={(e) => setText(e.target.value)} value={text()} />
<button onClick={addTodo} disabled={text().length === 0}>
Add
</button>
</div>
);
};
const TodoListFilter = ({ model }) => {
useObserver();
return (
<select
value={model.filter()}
onChange={(e) => model.setFilter(e.target.value)}
>
{FILTER_OPTIONS.map(({ name, value }) => (
<option key={value} value={value}>
{name}
</option>
))}
</select>
);
};
const Todo = ({ model }) => {
useObserver();
return (
<div className="todo">
<label>
<input
type="checkbox"
checked={model.done()}
onChange={model.toggleDone}
/>
<span
style={{ textDecoration: model.done() ? "line-through" : "none" }}
>
{model.text()}
</span>
</label>
</div>
);
};
export const TodoList = ({ model }) => {
useObserver();
return (
<div className="todo-list">
<button onClick={model.clearDone}>Clear done</button>
<TodoListFilter model={model} />
<NewTodoInput model={model} />
{model.visibleTodos().map((todo) => (
<Todo key={todo.id} model={todo} />
))}
</div>
);
};
License
MIT
Author
Eugene Daragan