Arbor does not enforce any project structure to use it, however, the following structure has proved quite fitting for our needs and perhaps, it can work for you as well:
my-app/
├── README.md
├── package.json
├── public/
│ └── index.html
└── src/
├── components/
├── helpers/
├── models/
├── selectors/
└── stores/
The more "Arbor-specific" part of the setup are the directories named models
, selectors
, and stores
, where:
components
: Home to your React components;helpers
: A place for helper functions used across the application;models
: It's where you can define your data model usually implemented using JS classes, but you can also define constructor functions that return your data types in plain JS objects/arrays if you prefer not to use more traditional OOP;selectors
: Here you can define "selector" functions used to select something from the store by a given condition or UUID so you can reuse this lookup logic across your application;stores
: This is where you define your Arbor store or stores if you decide to model your application using multiple ones.
Check out this sample todo app project in CodeSandbox for more insights.
One of Arbor's strengths is the fact you can rely on built-in JS constructs to model your application state, for instance defining custom types in a more OOP way by using classes.
When choosing that path, you may run into a common scenario where serializing custom types you end up losing type information, meaning, deserializing the serialized data will not give you back instances of the types you defined, but rather, literal objects and arrays.
One common solution is for you to create some serialization/deserialization logic that knows how to transform data accordingly. For small datasets that's usually OK, but as your data model becomes more complex, this task gets quite tedious and error-prone. To make this serialization/deserialization work easier, we've built @arborjs/json, check it out and let us know what you think 😁.
You can connect different apps (React or not) through a global Arbor store! You can subscribe to store updates from different apps to react to changes accordingly.
This can be used as an integration mechanism to keep two different apps in sync or aware of common data flows.
In React, you can also use Arbor to manage your component's local state to leverage all the reactivity goodies without necessarily having a global store.
To do that, simply pass an object as the local state initial value to useArbor
hook and it will return a reactive version of that object back to you. At this point, you can mutate that reactive object using Regular JS constructs and the React component will re-render accordingly.
In React apps, sometimes you need to track a certain value without forcing a re-render of your components. For that purpose, we usually resort to the useRef hook.
In Arbor, you can achieve a similar result with @detached
fields. Detached fields of @proxiable
classes are not attached to the store's state tree so you can freely change their value without triggering mutation events, which means React components will not re-render when these fields are updated.
Check out the @arborjs/react
README for a usage example.
Due to how the components tree works in React, every time a component re-renders it also re-renders its children. In other words, the entire component's subtree re-renders.
When rendering a subtree happens to be an expensive task, you may want to optimize your code so that the subtree only re-renders when needed. That's where React.memo can be helpful. Components wrapped with React.memo
will only re-render as part of their parent's rendering cycle, if their props have changed.
Arbor ensures objects in the store (a.k.a nodes of the state tree), have their memory reference stable across re-renders, only changing when that object is updated, same is true for methods defined in these objects, which means you can pass them as props to components wrapped with React.memo
and leverage this optimization. Example:
import { Arbor, useArbor } from "@arborjs/react"
const store = new Arbor([
{ id: 1, text: "Do the dishes", done: false },
{ id: 2, text: "Clean the house", done: true },
])
function TodoList() {
const todos = useArbor(store)
return (
<ul>
{todos.map((todo, i) => (
<TodoItem
key={todo.id}
todo={todo}
/>
))}
</ul>
)
}
const TodoItem = React.memo(({ todo }) => {
return (
<li>{todo.text}</li>
)
})
The previous section shows how to leverage React.memo
and Arbor's stable object references to prevent unnecessary re-renders of children components when the parent component re-renders. That, however, does not take care of one interesting scenario.
In the todo list example, the TodoList
component is the one connected to the store and when a TodoItem
updates, say you change their text, or mark them as done, the TodoList
will still re-render even though, it did not have to since the state of the list is not tied to the content of a todo or their completeness state.
In this example, an ideal scenario would be for the TodoList
component to only re-render when a new todo is added to the list or removed, changes to the contents of a todo should not impact how the list is displayed.
To achieve that desired ideal behavior, we can take advantage of Arbor's path tracking. Every time you connect a React component to the store using useArbor
, the value returned by that hook is a proxy that can track every part of the state accessed by the React component, in other words, it knows exactly which parts of the store's state the component depends on, and will only re-render the component when those dependencies are updated and nothing else.
Let's take the Todo List example from the previous section and optimize it to leverage Arbor's path tracking to ensure the TodoList
component does not change when a TodoItem
updates.
import { Arbor, detach, useArbor } from "@arborjs/react"
const store = new Arbor([
{ id: 1, text: "Do the dishes", done: false },
{ id: 2, text: "Clean the house", done: true },
])
function TodoList() {
const todos = useArbor(store)
return (
<ul>
{todos.map(todo => (
<TodoItem
key={todo.id}
id={todo.id}
/>
))}
</ul>
)
}
const TodoItem = React.memo(({ id }) => {
const todo = useArbor(store.state.find(todo => todo.id === id))
return (
<li>
{todo.text}
<button onClick={() => detach(todo)}>Delete</button>
</li>
)
})
Let's break that code snippet down:
- We no longer pass
todo
as prop toTodoItem
; - Instead, we pass the
id
of the todo and let theTodoItem
look up the todo from the store by its id and connect to the store viauseArbor
but specifically, it connects to the node representing that todo.
By doing that, we connect TodoList
and TodoItem
to the store but each component is scoped to updates affecting the parts of the state they depend on.
The TodoItem
component will re-render every time the text
or id
of the todo changes, whereas the TodoList
will re-render only if the state of the array changes, e.g. a todo is removed or added, or if the id
of a todo item changes since the component references that field within the map loop.
This optimization can be huge when dealing with large lists where items can be updated while the list does not have to re-render.