Best Practices and Use Cases
Simple use cases
1. Simple counter
import { useDisposable } from '@dxbox/use-less-react/client';
import { AutoDispose } from '@dxbox/use-less-react/classes';
@AutoDispose
class Counter implements Disposable {
count = new ReactiveValue(0);
[Symbol.dispose](): void {
// no custom dispose logic
}
increment() {
this.count.set(c => c + 1);
}
decrement() {
this.count.set(c => c - 1);
}
}
function CounterComponent() {
const counter = useDisposable(() => new Counter());
const { count } = useReactiveValues({ count: counter.count });
return (
<div>
<span>{count}</span>
<button onClick={() => counter.increment()}>+</button>
<button onClick={() => counter.decrement()}>-</button>
</div>
);
}
2. Simple form
import { useDisposable } from '@dxbox/use-less-react/client';
import { AutoDispose } from '@dxbox/use-less-react/classes';
@AutoDispose
class FormManager implements Disposable {
name = new ReactiveValue("");
email = new ReactiveValue("");
[Symbol.dispose](): void {
// no custom dispose logic
}
submit() {
// Submit logic
}
}
function FormComponent() {
const form = useDisposable(() => new FormManager());
const { name, email } = useReactiveValues({
name: form.name,
email: form.email
});
return (
<form onSubmit={(e) => { e.preventDefault(); form.submit(); }}>
<input
value={name}
onChange={(e) => form.name.set(e.target.value)}
/>
<input
value={email}
onChange={(e) => form.email.set(e.target.value)}
/>
<button type="submit">Submit</button>
</form>
);
}
3. Todo list
import { useDisposable } from '@dxbox/use-less-react/client';
import { AutoDispose } from '@dxbox/use-less-react/classes';
interface Todo {
id: string;
text: string;
completed: boolean;
}
@AutoDispose
class TodoManager implements Disposable {
todos = new ReactiveArray<Todo>([]);
filter = new ReactiveValue<"all" | "active" | "completed">("all");
[Symbol.dispose](): void {
// no custom dispose logic
}
addTodo(text: string) {
this.todos.set(draft => {
draft.push({ id: crypto.randomUUID(), text, completed: false })
});
}
toggleTodo(id: string) {
this.todos.set(draft => {
const todo = draft.find(t => t.id === id);
if (todo) {
todo.completed = !todo.completed;
}
});
}
filteredTodos = new ComputedValue(
() => {
const todos = this.todos.get();
const filter = this.filter.get();
if (filter === "active") return todos.filter(t => !t.completed);
if (filter === "completed") return todos.filter(t => t.completed);
return todos;
},
[this.todos, this.filter],
{ memo: true }
);
}
function TodoComponent() {
const manager = useDisposable(() => new TodoManager());
const { todos, filter, filteredTodos } = useReactiveValues({
todos: manager.todos,
filter: manager.filter,
filteredTodos: manager.filteredTodos
});
// you can still have useState, especially for small local states!
const [newTodoName, setNewTodoName] = useState("");
// or you can use a ReactiveValue for newTodoName inside TodoManager/elsewhere as well!
return (
<div>
<input
value={newTodoName}
onChange={e => { setNewTodoName(e.target.value); }}
onKeyPress={(e) => {
if (e.key === "Enter") {
manager.addTodo(newTodoName);
setNewTodoName("");
}
}}
/>
{filteredTodos.map(todo => (
<div key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => manager.toggleTodo(todo.id)}
/>
{todo.text}
</div>
))}
</div>
);
}
Intermediate use cases
4. Store with batching
import { AutoDispose } from "@dxbox/use-less-react/classes";
@AutoDispose
class ShoppingCart implements Disposable {
private store = new ReactiveStore({
items: new ReactiveArray<CartItem>([]),
discount: new ReactiveValue(0),
shipping: new ReactiveValue(0),
});
[Symbol.dispose](): void {
// no custom dispose logic
}
get values() {
return this.store.values;
}
// Calculate totals in batch
async updateCart(items: CartItem[], discount: number, shipping: number) {
await this.store.batchNotifications(({ items, discount, shipping }) => {
items.set(items);
discount.set(discount);
shipping.set(shipping);
});
// Single notification at the end
}
total = new ComputedValue(() => {
const { items, discount, shipping } = this.store.values;
const subtotal = items.get().reduce((sum, item) => sum + item.price, 0);
return subtotal - discount.get() + shipping.get();
}, [
this.store.values.items,
this.store.values.discount,
this.store.values.shipping,
]);
}
5. Rollback for transactions
class PaymentProcessor {
private store = new ReactiveStore({
balance: new ReactiveValue(1000),
pending: new ReactiveValue(0),
});
async processPayment(amount: number): Promise<void> {
await this.store.rollbackBlock(({ balance, pending }, rollback) => {
balance.set(currentBalance - amount);
if (balance.get() < 0) {
rollback(); // rollback operation
}
pending.set(amount);
const result = await this.callPaymentAPI(amount); // if error, rollback
if (!result.status !== 200) {
rollback(); // explicit rollback
}
});
}
}
6. ReactiveObject with toPlainObject
import { useDisposable } from '@dxbox/use-less-react/client';
import { AutoDispose } from '@dxbox/use-less-react/classes';
interface User {
name: string;
tags: Set<string>;
metadata: Map<string, string>;
}
@AutoDispose
class UserManager implements Disposable {
user = new ReactiveObject<User>(
{
name: "John",
tags: new Set(["admin", "user"]),
metadata: new Map([["key", "value"]])
},
{
// transform the ReactiveObject to POJO, if needed
toPlainObject: (v) => ({
name: v.name,
tags: Array.from(v.tags),
metadata: Object.fromEntries(v.metadata)
})
}
);
[Symbol.dispose](): void {
// no custom dispose logic
}
}
function UserComponent() {
const manager = useDisposable(() => new UserManager());
const { user } = useReactiveValues({ user: manager.user });
// user is already converted to plain object
// user.tags is an array, not a Set
// user.metadata is an object, not a Map
return (
<div>
<p>{user.name}</p>
<ul>
{user.tags.map(tag => <li key={tag}>{tag}</li>)}
</ul>
</div>
);
}
Advanced use cases
7. Composition with ReactiveReference
class SharedState {
count = new ReactiveValue(0);
}
class ComponentA {
private shared: SharedState;
countRef: ReactiveReference<ReactiveValue<number>>;
constructor(shared: SharedState) {
this.shared = shared;
this.countRef = new ReactiveReference(shared.count);
}
}
class ComponentB {
private shared: SharedState;
countRef: ReactiveReference<ReactiveValue<number>>;
constructor(shared: SharedState) {
this.shared = shared;
this.countRef = new ReactiveReference(shared.count, { readonly: false });
}
increment() {
this.countRef.set((c) => c + 1); // Modifies shared state
}
}
8. Manager pattern with store and batched notifications
import {
AutoDispose,
ReactiveValue,
ReactiveObject,
ReactiveStore,
} from "@dxbox/use-less-react/classes";
@AutoDispose
class UserManager implements Disposable {
private store = new ReactiveStore({
currentUser: new ReactiveObject<User | null>(null),
preferences: new ReactiveObject<Preferences>({}),
session: new ReactiveValue<Session | null>(null),
});
[Symbol.dispose](): void {
// no custom dispose logic
}
get values() {
return this.store.values;
}
async login(credentials: Credentials) {
await this.store.batchNotifications(async ({ currentUser, session }) => {
const session = await this.api.login(credentials);
session.set(session);
currentUser.set(session?.user ?? null);
});
}
async updatePreferences(prefs: Partial<Preferences>) {
this.store.values.preferences.set((current) => ({ ...current, ...prefs }));
}
}
Best practices
1. Separation of logic and UI
Good:
import { useDisposable } from "@dxbox/use-less-react/client";
import {
AutoDispose,
ReactiveValue,
} from "@dxbox/use-less-react/classes";
// Logic in a class/object
@AutoDispose
class Counter implements Disposable {
count = new ReactiveValue(0);
[Symbol.dispose](): void {
// no custom dispose logic
}
increment() {
this.count.set((c) => c + 1);
}
}
// UI in component
function CounterUI() {
const counter = useDisposable(() => new Counter());
const { count } = useReactiveValues({ count: counter.count });
return <button onClick={() => counter.increment()}>{count}</button>;
}
Avoid:
// Logic in component
function CounterUI() {
const [count, setCount] = useState(0);
const increment = useCallback(() => setCount((c) => c + 1), []);
return <button onClick={increment}>{count}</button>;
}
2. Use store for related values
Good:
import { AutoDispose } from "@dxbox/use-less-react/classes";
@AutoDispose
class UserManager implements Disposable {
private store = new ReactiveStore({
name: new ReactiveValue(""),
email: new ReactiveValue(""),
});
[Symbol.dispose](): void {
// no custom dispose logic
}
async updateProfile(name: string, email: string) {
await this.store.batchNotifications(({ name, email }) => {
name.set(name);
email.set(email);
});
}
}
Not the best approach: uncoordinated values
class UserManager {
name = new ReactiveValue("");
email = new ReactiveValue("");
// Separate notifications, not coordinated
updateProfile(name: string, email: string) {
this.name.set(name);
this.email.set(email);
}
}
3. Cleanup subscriptions
The hooks (useReactiveValues, useReactiveStoreValues) handle cleanup automatically. If you subscribe manually, remember to cleanup:
Good:
useEffect(() => {
const unsubscribe = value.subscribe(() => {});
return unsubscribe; // Automatic cleanup
}, [value]);
Avoid:
useEffect(() => {
value.subscribe(() => {}); // Memory leak!
}, [value]);
4. ComputedValue for derived values
Good:
import { AutoDispose } from "@dxbox/use-less-react/classes";
@AutoDispose // REQUIRED - class must be disposed
class Cart implements Disposable {
items = new ReactiveArray<Item>([]);
total = new ComputedValue(
() => this.items.get().reduce((sum, item) => sum + item.price, 0),
[this.items],
);
[Symbol.dispose](): void {
// no custom dispose logic
}
}
Avoid:
class Cart {
items = new ReactiveArray<Item>([]);
total = new ReactiveValue(0);
// Must remember to update manually
addItem(item: Item) {
this.items.set(current => [...current, item]);
this.total.set(this.items.get().reduce(...)); // Easy to forget
}
}
The ESLint plugin that comes with the library will warn you of missing @AutoDispose decorator on classes with Disposable properties. Include it into your ESLint configuration!
5. Type safety
const store = new ReactiveStore({
count: new ReactiveValue(0),
name: new ReactiveValue(""),
});
// TypeScript knows the types
store.values.count.set(10); // OK
store.values.count.set("invalid"); // Type error
6. Error handling with rollback
Good:
async processPayment(amount: number) {
await this.store.rollbackBlock(async ({ balance }, rollback) => {
balance.set(current => current - amount);
const result = await this.api.charge(amount); // also rollback on uncaught errors
if (!result.data.success) {
rollback(); // explicit rollback
}
});
}
Common patterns
Manager pattern
A class that manages business logic and state.
import { AutoDispose } from "@dxbox/use-less-react/classes";
@AutoDispose
class FeatureManager implements Disposable {
private store = new ReactiveStore({
/* ... */
});
[Symbol.dispose](): void {
// no custom dispose logic
}
get values() {
return this.store.values;
}
async performAction() {
// Business logic
}
}
Dependency Injection
For example, use an interface as a "Port" for injecting a service in the constructor.
import { AutoDispose } from '@dxbox/use-less-react/classes';
interface ApiService {
async fetchData(): Promise<Data>;
}
@AutoDispose
class DataManager implements Disposable {
constructor(private api: ApiService) {...}
data = new ReactiveValue<Data | null>(null);
[Symbol.dispose](): void {
// no custom dispose logic
}
async load() {
const data = await this.api.fetchData();
this.data.set(data);
}
}
Repository pattern
Abstraction for data access.
import { AutoDispose } from '@dxbox/use-less-react/classes';
interface UserRepository {
async findById(id: string): Promise<User>;
async save(user: User): Promise<void>;
}
@AutoDispose
class UserManager implements Disposable {
constructor(private repo: UserRepository){...}
user = new ReactiveValue<User | null>(null);
[Symbol.dispose](): void {
// no custom dispose logic
}
async loadUser(id: string) {
const user = await this.repo.findById(id);
this.user.set(user);
}
}
Memory management with useDisposable
With classes - automatic disposal (recommended):
import { useDisposable } from "@dxbox/use-less-react/client";
import { AutoDispose } from "@dxbox/use-less-react/classes";
@AutoDispose
class Counter implements Disposable {
store = new ReactiveStore({ count: new ReactiveValue(0) });
doubled = new ComputedValue(
() => this.store.values.count.get() * 2,
[this.store.values.count],
);
[Symbol.dispose](): void {
// no custom dispose logic
}
}
function Component() {
// Automatically disposed when component unmounts
const manager = useDisposable(() => new Counter());
// ...
}
With plain objects - using makeDisposableObject:
import { useDisposable } from "@dxbox/use-less-react/client";
import { makeDisposableObject } from "@dxbox/use-less-react/classes";
function createCounter() {
return makeDisposableObject({
store: new ReactiveStore({ count: new ReactiveValue(0) }),
doubled: new ComputedValue(
() => this.store.values.count.get() * 2,
[this.store.values.count],
),
});
// Automatically detects 'store' and 'doubled' as disposable
}
function Component() {
const manager = useDisposable(() => createCounter());
// ...
}
Key points:
- Use
useDisposablefor component-scoped or context-provided instances - With classes: use
@AutoDispose- it automatically detects all properties that implementDisposable(haveSymbol.dispose) - With plain objects: use
makeDisposableObjectfor automatic disposal (recommended) or manually implementdispose()(not recommended) - Important:
ComputedValueinstances MUST be disposed (automatically via@AutoDispose,makeDisposableObject, or manually) because dependencies don't automatically dispose their subscribers - Global stores (singletons) don't need disposal - they live for the entire application lifetime
- When to use classes vs plain objects:
- Classes: Use when you need inheritance, private methods, or prefer class-based patterns
- Plain objects: Use when you prefer functional factory functions or need to avoid class syntax
When to use this approach
This approach works well when:
- You have complex business logic that benefits from being separate from UI
- You need to reuse logic across different contexts (client, server, workers)
- You want to test business logic without React testing utilities
- You're building applications that need to work in both SSR and CSR contexts
- You prefer organizing code in objects or classes rather than hooks
- Remember: this is an experimental approach. Use it where it makes sense for your project, and feel free to mix it with traditional React patterns where appropriate.