Managing the state in an application is not an easy task. In some cases, the state of an application can be part of the component tree, in a natural way. However, there are situations where some parts of the state need to be displayed in various parts of the user interface, and then, it is not obvious which component should own which part of the state.
Owl's solution to this issue is a centralized store. It is a class that owns
some (or all) state, and lets the developer update it in a structured way, with
actions
. Owl components can then connect to the store to read their relevant
state, and they will be rerendered if the state is updated.
Note: Owl store is inspired by React Redux and VueX.
Here is what a simple store looks like:
const actions = {
addTodo({ state }, message) {
state.todos.push({
id: state.nextId++,
message,
isCompleted: false,
});
},
};
const state = {
todos: [],
nextId: 1,
};
const store = new owl.Store({ state, actions });
store.on("update", null, () => console.log(store.state));
// updating the state
store.dispatch("addTodo", "fix all bugs");
This example shows how a store can be defined and used. Note that in most cases, actions will be dispatched by connected components.
The store is a simple owl.EventBus
that triggers update
events
whenever its state is changed. Note that these events are triggered only after a
microtask tick, so only one event will be triggered for any number of state changes in a
call stack.
Also, it is important to mention that the state is observed (with an owl.Observer
),
which is the reason why it is able to know if it was changed. See the
Observer's documentation for more details.
The Store
class is quite small. It has two public methods:
- its constructor
dispatch
The constructor takes a configuration object with four (optional) keys:
- the initial state
- the actions
- the getters
- the environment
const config = {
state,
actions,
getters,
env,
};
const store = new Store(config);
Actions are used to coordinate state changes. It can be used for both synchronous and asynchronous logic.
const actions = {
async login({ state }, info) {
state.loginState = "pending";
try {
const loginInfo = await doSomeRPC("/login/", info);
state.loginState = loginInfo;
} catch (e) {
state.loginState = "error";
}
},
};
The first argument to an action method is an object with four keys:
state
: the current state of the store content,dispatch
: a function that can be used to dispatch other actions,getters
: an object containing all getters defined in the store,env
: the current environment. This is useful sometimes, in particular if an action needs to apply some side effects (such as performing an rpc), and therpc
method is located in the environment.
Actions are called with the dispatch
method on the store, and can receive an
arbitrary number of arguments.
store.dispatch("login", someInfo);
Note that anything returned by an action will also be returned by the dispatch
call.
Also, it is important to be aware that we need to be careful with asynchronous logic. Each state change will potentially trigger a rerendering, so we need to make sure that we do not have a partially corrupted state. Here is an example that is likely not a good idea:
const actions = {
async fetchSomeData({ state }, recordId) {
state.recordId = recordId;
const data = await doSomeRPC("/read/", recordId);
state.recordData = data;
},
};
In the previous example, there is a period of time in which the state has a
recordId
which does not correspond to the recordData
. It is more likely that
we want an atomic update: updating the recordId
at the same time as the recordData
values:
const actions = {
async fetchSomeData({ state }, recordId) {
const data = await doSomeRPC("/read/", recordId);
state.recordId = recordId;
state.recordData = data;
},
};
Usually, data contained in the store will be stored in a normalized way. For example,
{
posts: [{id: 11, authorId: 4, content: 'Greetings'}],
authors: [{id: 4, name: 'John'}]
}
However, the user interface will probably need some denormalized data like
{id: 11, author: {id: 4, name: 'John'}, content: 'Greetings'}
This is what getters
are for: they give a centralized way to process and
transform the data contained in the store.
const getters = {
getPost({ state }, id) {
const post = state.posts.find((p) => p.id === id);
const author = state.authors.find((a) => a.id === post.id);
return {
id,
author,
content: post.content,
};
},
};
// somewhere else
const post = store.getters.getPost(id);
Getters take at most one argument.
Note that getters are not cached.
At some point, we need a way to interact with the store from a component. This
means that the component needs a reference to the store. By default, it looks
for it in the env.store
key. However, this can be configured with the useStore
hook.
Every component-store interactions are done with the help of the three store hooks:
useStore
to subscribe a component to some part of the store state,useDispatch
to get a reference to a dispatch function,useGetters
to get a reference to the getters defined in the store.
Assume we have this store:
const actions = {
increment({ state }, val) {
state.counter.value += val;
},
};
const state = {
counter: { value: 0 },
};
const store = new owl.Store({ state, actions });
To make it accessible to the complete application, we will put it in the environment:
// in this example, the root component is App
App.env.store = store;
A counter component can then select this value and dispatch an action like this:
class Counter extends Component {
counter = useStore((state) => state.counter);
dispatch = useDispatch();
}
const counter = new Counter({ store, qweb });
<button t-name="Counter" t-on-click="dispatch('increment')">
Click Me! [<t t-esc="counter.value"/>]
</button>
The useStore
hook is used to select some part of the store state. It accepts
two arguments:
- a selector function, which takes the store state as first argument (and the component props as second argument) and which must return the part of the store state that will be made available and observed for changes,
- optionally, an object which can have the following optional keys:
- a
store
key containing a store object if we want to use another store than the default store, - an
isEqual
key containing an equality function if we want to specialize the comparison (the function must accept two arguments: the previous result and the new result, and must return whether they are equal), - and an
onUpdate
key containing an update function if we want to execute an arbitrary code every time the selected state changes (the function will receive one argument, the new result, and can execute arbitrary code).
- a
If the useStore
selector returns a sub part of the store state, the component
will only be rerendered whenever this part of the state changes. Otherwise, it
will perform a strict equality check (unless the isEqual
option is defined,
then it will call it) and will update the component every time this check fails.
Note that if the selector function returns a primitive type, the result of
useStore
will be immutable and it will not react to changes. In this case, it
is important to define the onUpdate
option to properly update the value
manually when it changes.
Also, the return value from useStore
is not supposed to be modified. The store
state should only be updated with actions.
The useDispatch
hook is useful when a component needs to be able to dispatch
actions. It takes an optional argument, which is a store. If not given, it will
use the store in the environment.
Note that a component does not need to be connected in any other way to the store. For example:
class DoSomethingButton extends Component {
static template = xml`<button t-on-click="dispatch('something')">Click</button>`;
dispatch = useDispatch();
}
The useGetters
hook is useful when a component needs to be able to use the
getters defined in a store. It takes an optional argument, which is a store. If
not given, it will use the store in the environment.
Note that a component does not need to be connected in any other way to the store. For example:
class InfoButton extends Component {
static template = xml`<span><t t-esc="getters.somevalue()"></span>`;
getters = useGetters();
}
The Store
class and the useStore
hook try to be smart and to optimize as much
as possible the rendering and update process. What is important to know is:
- components are always updated in the order of their creation (so, parent before children),
- they are updated only if they are in the DOM,
- if a parent is asynchronous, the system will wait for it to complete its update before updating other components,
- in general, updates are not coordinated. This is not a problem for synchronous components, but if there are many asynchronous components, this could lead to a situation where some part of the UI is updated and some other part of the UI is not updated.
- avoid asynchronous components as much as possible. Asynchronous components lead to situations where parts of the UI is not updated immediately,
- do not be afraid to connect many components, parent or children if needed. For
example, a
MessageList
component could get a list of ids in itsuseStore
call and aMessage
component could get the data of its own message, - since the
useStore
function is called for each connected component, for each state update, it is important to make sure that these functions are as fast as possible.