- Recube
Recube
is a streamlined state management library for React applications. It focuses on simplifying state structuring and enhancing rendering optimization, making it an effective choice for developers building scalable and maintainable React projects.
Managing state in JavaScript applications can be challenging, especially when it comes to tracking state changes and updating related states accordingly. Recube offers an elegant solution to manage reactive state effortlessly and efficiently. At its core, Recube is built around two main concepts: state and action. The state serves as the application's data store, while actions function like app events. The state listens for action dispatches and updates its value in response to each specific action.
import { state, action } from 'recube';
// define actions
const increment = action();
const decrement = action();
// define states
const count = state(1)
// using chaining method to describe how state reacts with actions
.when(increment, x => x + 1)
.when(decrement, x => x - 1);
// state is function, call it to get current value of the state
console.log(count()); // 1
// action is also a function, therefore, calling a function equates to dispatching an action
increment();
console.log(count()); // 2
decrement();
console.log(count()); // 1
Recube
state
and action
can be used with VanillaJS apps. If we want to use it with a React app, we need to import cube
. Cube
is a React component that tracks state changes and then re-renders. Cube
automatically connects to the state, so you don't need to use any other hooks.
import { state, action } from 'recube';
import { cube } from 'recube/react';
// just 3 lines for Counter app, no Provider/Context/Hook needed
const increment = action();
const count = state(1).when(increment, x => x + 1);
const App = cube(() => <h1 onClick={increment}>Count: {count()}</h1>);
When working with other state management libraries, you often need to explicitly define how many states or stores a component must connect to. Recube simplifies connecting to the state as much as possible, helping to minimize code and improve rendering performance.
// without recube
const App = () => {
// the component have to connect to 3 atoms
const s1 = useAtom(atom1);
const s2 = useAtom(atom2);
const s3 = useAtom(atom3);
return <div>{s1 === condition ? s2 : s3}</div>;
};
// with recube
const App = cube(() => {
// The component only connects to state1 and state2, or state1 and state3
return <div>{state1() === condition ? state2() : state3()}</div>;
});
Recube
can be installed by adding the recube
package to your project:
npm install recube
# or
yarn add recube
Once installed via your package manager of choice, you're ready to import it in your app.
Recube
has three essentials: state
, action
, and cube
. They operate as depicted in the diagram below.
dispatch ─────┐ ┌────── update
│ │ │ │
│ ┌────┴────┐ ┌────▼────┐ │
└────► ACTION ├── mutate ──► STATE ├─────┘
└────▲────┘ └────┬────┘
│ │
dispatch update
│ ┌─────────┐ │
└──────│ CUBE ◄─────┘
└─────────┘
- States listen action dispatching and mutate their values according to action results.
- States can be derived from others and re-compute when dependency states change.
- Cubes connects to states and update when the values of those states change.
Let's use recube
in a real world scenario. We're going to build a todo list app, where you can add and remove items in a todo list. We'll start by modeling the state. We're going to need a state that holds a list of todos first, which we can represent with an Array:
import { state, action } from 'recube';
// define actions
// assuming that the action has a payload as todo text
const addTodo = action();
// assuming that the action has a payload as todo object
const removeTodo = action();
const todos = state([{ text: 'Buy groceries' }, { text: 'Walk the dog' }])
.when(
addTodo,
// the first parameter is prev state value
// the second parameter is action result (if the action has body) or action payload (if the action has no body)
(prev, text) => [...prev, { text }],
)
.when(removeTodo, (prev, todo) => prev.filter(x => x !== todo));
// simulate adding a new todo
addTodo('Tidy up'); // dispatch addTodo with payload
// Check that it added the new item
console.log(todos());
// Logs: [{text: "Buy groceries"}, {text: "Walk the dog"}, {text: "Tidy up"}]
Start building UI
import { cube } from 'recube/react';
const TodoList = cube(() => {
const inputRef = useRef();
const handleClick = () => {
addTodo(inputRef.current.value);
inputRef.current.value = '';
};
return (
<>
<input ref={inputRef} />
<button onClick={handleClick}>Add</button>
<ul>
{todos().map(todo => (
<li>
{todo.text} <button onClick={() => removeTodo(todo)}>❌</button>
</li>
))}
</ul>
</>
);
});
And with that we have a fully working todo app!
Let's add one more feature to our todo app: each todo item can be checked off as completed, and we'll show the user the number of items they've completed. To do that we'll use computed state, which is computed based on the values of other states.
import { state } from 'recube';
const todos = state([
{ text: 'Buy groceries', completed: true },
{ text: 'Walk the dog', completed: false },
]);
// create a computed state from other states
const completed = state(() => {
// When `todos` changes, this re-runs automatically:
return todos().filter(todo => todo.completed).length;
});
// Logs: 1, because one todo is marked as being completed
console.log(completed());
To reduce unnecessary rendering for components, we commonly utilize the memoization technique by utilize the memo()
function
const MemoizedComp = memo(props => {
return <button onClick={props.onClick}>Click me</button>;
});
This way has the drawback that we need to memoize all callbacks passed to the MemoizedComp. If we neglect to memoize a callback somewhere, the process of memoization becomes ineffective.
const Page1 = () => {
const handleClick = useCallback(() => {}, [dependencies]);
return <MemoizedComp onClick={handleClick} />;
};
const Page2 = () => {
// no memoization for the callback, MemoizedComp still renders unnecessary
const handleClick = () => {};
return <MemoizedComp onClick={handleClick} />;
};
With Recube, we don't need to worry about memoizing callbacks.
const MemoizedComp = cube(props => {
return <button onClick={props.onClick}>Click me</button>;
});
const Page1 = () => {
// no useCallback needed
const handleClick = () => {};
return <MemoizedComp onClick={handleClick} />;
};
const Page2 = () => {
// no useCallback needed
const handleClick = () => {};
return <MemoizedComp onClick={handleClick} />;
};
Cube
can detect which props a component are using and will re-render only when those props change. In cases where a component doesn't use any props, Cube
will never re-render
const UserName = cube(props => {
// the component utilizes text prop in rendering phase, this means it only re-renders if text prop changed
return <div>{props.text}</div>;
});
const Alert = cube(props => {
// the component does not utilize any props in rendering phase but in callback
// so the component will never re-render no matter its parent component force re-render
return <button onClick={() => alert(props.text)}>Click me</button>;
});
const Parent = () => {
const [count, setCount] = useState(1);
return (
<>
<button onClick={() => setCount(count + 1)}>Rerender</button>
{/* even, if the parent component tries to pass unutilized props to the Alert, the Alert does not re-render*/}
<Alert text="Hi Ging" count={count} />
<UserName text="Ging" count={count}>
</>
);
};
Caveat: Rendering optimization is automatically handled by Recube. In
development
environment, usinghot module reloading
may not be effective withcube
. Therefore, we can disable this feature by using thepropsChangeOptimization
function.
import { propsChangeOptimization } from 'recube/react';
if (process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'test') {
propsChangeOptimization(false);
}
Recube
does rendering optimizing for externally passed props. However, to optimize data changes within the component, we still need to use useCallback
, useRef
, useState
, useEffect
, and useMemo
for handling them.
const MemoizedComp = cube(props => {
const [count, setCount] = useState(0);
const numberList = useMemo(
() => new Array(count).fill().map((x, i) => i),
[count],
);
const increment = useCallback(() => {
// actually we can call setCount(prev => prev + 1) but this is for demonstration how hard to manage dependencies with useCallback
setCount(count + 1);
}, [count, setCount]);
useEffect(() => {
// do something on mount
return () => {
// do something on unmount
};
}, []);
return (
<>
<ul>
{numberList.map(x => (
<li key={x}>{x}</li>
))}
</ul>
<NonCubeButton onClick={increment} />
</>
);
});
Recube
provides useStable
hook. With this hook, we can easily optimize data changes within the component
import { useStable } from 'recube/react';
import { createRef } from 'react';
const MemoizedComp = cube(props => {
// the init function will be called once
const stable = useStable(() => {
// local actions
const increment = action();
// local states
const count = state(0).when(increment, x => x + 1);
const numberList = state(() => new Array(count()).fill().map((x, i) => i));
// creating ref for later use
const buttonRef = createRef();
return {
count,
numberList,
buttonRef,
increment,
// even we can access component props in stable scope
greeting() {
alert(props.greeting);
},
onMount() {
// do something on mount
// the onMount function can return unmount action like useEffect
return () => {
// do something on unmount
};
},
onUnmount() {
// do something on unmount
},
};
});
return (
<>
<ul>
{stable.numberList.map(x => (
<li key={x}>{x}</li>
))}
</ul>
<NonCubeButton ref={stable.buttonRef} onClick={stable.increment} />
</>
);
});
With each component re-render, the hooks inside the component won't need to excessively check for dependencies changes.
We can modularize the logic, using local or global state as needed.
import { useStable } from 'recube/react';
import { createRef } from 'react';
// we can share this logic for many components
const counterLogic = () => {
const increment = action();
const count = state(0).when(increment, x => x + 1);
return { increment, count };
};
const MemoizedComp = cube(props => {
// the init function will be called once
const stable = useStable(() => {
const counter = counterLogic();
const numberList = state(() =>
new Array(counter.count()).fill().map((x, i) => i),
);
// creating ref for later use
const buttonRef = createRef();
return {
...counter,
numberList,
buttonRef,
// even we can access component props in stable scope
greeting() {
alert(props.greeting);
},
onMount() {
// do something on mount
// the onMount function can return unmount action like useEffect
return () => {
// do something on unmount
};
},
onUnmount() {
// do something on unmount
},
};
});
return (
<>
<ul>
{stable.numberList.map(x => (
<li key={x}>{x}</li>
))}
</ul>
<NonCubeButton ref={stable.buttonRef} onClick={stable.increment} />
</>
);
});
If you want to create stable callbacks that have accesses to the unstable values, you can use this overload of useStable
const MyComponent = props => {
const hookResult = useSomething();
// useStable retrieves callback map and return new stable callback map with same shape
const { handleClick, getHookResult } = useStable({
handleClick() {
alert(`${hookResult} - ${props.value}`);
},
// if you pass unstable values to useStable, it will create stable getters for those values
getHookResult: hookResult,
});
console.log(typeof getHookResult); // function
};
You can use the callback map and init function together to create stable callbacks that needs to access unstable values.
const MyComponent = props => {
const hookResult = useSomething();
// useStable will pass stable callbacks to init function as first parameter
const {} = useStable({ getHookResult: hookResult }, ({ getHookResult }) => {
const stableVar = [];
return {
handleClick() {
alert(getHookResult());
},
};
});
};
Remember that useStable can be used anywhere, even outside the cube component.
Recube
enables a new way of thinking about how React components update: to observe state changing rather than observing renders. In this pattern, components render once and individual elements re-render themselves. This enables what we call a “render once” style - components render only the first time and state changes trigger only the tiniest possible re-renders.
import { state } from 'recube';
import { part } from 'recube/react';
const count = state(0);
const doubledCount = state(() => count() * 2);
const App = () => {
const handleClick = () => {
count.set(prev => prev + 1);
};
return (
<>
<h1>Count: {part(count)}</h1>
<h1>Doubled Count: {part(doubledCount)}</h1>
{/* part can be used with normal compute function, not only state */}
<h1>Tripled Count: {part(() => count() + doubledCount())}</h1>
<button onClick={handleClick}>Increment</button>
</>
);
};
With this approach, the component will never re-render even if the count
state changes because frequently changing scope is encapsulated by the part
function. The part(fn)
function will track changes within the fn
function and trigger a re-render, without affecting the host component.
Remember that part function can be used anywhere, even outside the cube component.
Recube state
itself does not distinguish between sync or async data; it can store anything. The difference arises when using and modifying async data. Let's explore the example below.
// assume removeTodo action retrieves todo object as payload
const removeTodo = action();
// assume replaceAllTodos action retrieves todo object as payload
const replaceAllTodos = action();
// the todos state stores the todo list that is fetched from network
const todos = state(() =>
fetch('https://jsonplaceholder.typicode.com/todos').then(res => res.json()),
)
.when(
removeTodo,
// this reducer returns new promise object
async (prev, todo) => {
// what we should do here, the prev is promise object
// we should await until prev promise fulfilled
const todos = await prev;
return todos.filter(x => x !== todo);
},
)
.when(replaceAllTodos, (prev, newTodos) => {
// the prev might be todo list or promise of todo list
// we dont need to wait until prev is fulfilled, just return new todo list immediately
return newTodos;
});
We can rewrite the above example and use the alter
function for a more concise code.
import { alter } from 'recube';
const removeTodo = action();
const replaceAllTodos = action();
const todos = state(() =>
fetch('https://jsonplaceholder.typicode.com/todos').then(res => res.json()),
)
.when(
removeTodo,
// alter retrieves a mutation like immer's produce function
// we dont need to return new state value and wait for prev state value fulfilled
alter((todos, todo) => {
const index = todos.findIndex(x => x.id === todo.id);
if (index !== -1) todos.splice(index, 1);
}),
)
// no need to use alter here
.when(replaceAllTodos, (_, newTodos) => newTodos);
The differences when using the alter function are:
-
If the previous state value is a promise object, the
alter
function will wait until the promise object is fulfilled before calling the mutation. The pseudo code below describes how it worksconst alter = mutation => { return prev => { if (isPromiseLike(prev)) { return prev.then(mutation); } return mutation(prev); }; };
-
There is no need to return the next state value inside the mutation. Use the previous state value as a mutable object.
-
If the previous state value is promise object and it is fulfilled, the
alter
function will immediately call the mutation without creating a new promise object.
Recube is highly compatible with Suspense and ErrorBoundary. We can use wait function to render a async state value. First characteristic of the wait() function: retrieving state and returning the state value. if the promise object is not fulfilled, it will be thrown up to the Suspense element for handling. If it is rejected, the error object will be thrown up to the ErrorBoundary for handling.
import { state, wait } from 'recube';
import { cube } from 'recube/react';
const todos = state(() =>
fetch('https://jsonplaceholder.typicode.com/todos').then(res => res.json()),
);
// TodoList cube contains straightforward logic, no loading/error checking logic needed
const TodoList = cube(() => {
return (
<ul>
{wait(todos).map(todo => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
);
});
const App = () => {
return (
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<Suspense fallback={<div>Loading...</div>}>
<TodoList />
</Suspense>
</ErrorBoundary>
);
};
The wait()
function can retrieve multiple states at once and wait until all those state values has been fulfilled
import { wait } from 'recube';
const [value1, value2, value3] = wait([state1, state2, state3]);
console.log(value1, value2, value3);
// or
const result = wait({ state1, state2, state3 });
console.log(result.state1, result.state2, result.state3);
If you are fan of recoil / react-query, Recube
also provides the loadable
function. The loadable
function retrieves one or multiple states and returns one or multiple Loadable objects accordingly. The Loadable object has following properies: data, loading, error
import { loadable } from 'recube';
const TodoList = cube(() => {
const { data, loading, error } = loadable(todos);
if (loading) return <div>Loading...</div>;
if (error) return <div>Something went wrong</div>;
return (
<ul>
{data.map(todo => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
);
});
The loadable function can also work with any promise object
import { loadable } from 'recube';
// loadable function can work everywhere, no matter cube or normal component
const NormalComponent = () => {
const [result, setResult] = useState();
const loadSomething = () => {
setResult(fetch('api/get/something').then(res => res.json()));
};
const { data, loading, error } = loadable(result);
if (loading) return <div>Loading...</div>;
if (error) return <div>Something went wrong</div>;
return <div>{data}</div>;
};
Recube
operates similarly to Redux, where any state
changes by action
dispatching. However, sometimes we want to swiftly change the state value directly without dispatching any action
. Recube
allows you to do this using state.set.
// if the state has no action listening, its value can be changed by calling set method
const count1 = state(1);
count1.set(1); // OK
// using reducer
count1.set(prev => prev + 1); // OK
const increment = action();
const count2 = state(2).when(increment, prev => prev + 1);
count2.set(3); // ❌ ERROR: The state value can be changed by dispatching action only
A mini version of counter app that using state
and part
only
import { state } from 'recube';
import { part } from 'recube/react';
// just 2 lines only
const count = state(1);
const App = () => <h1 onClick={() => count.set(x => x + 1)}>{part(count)}</h1>;