Skip to content

Latest commit

 

History

History
395 lines (312 loc) · 15.3 KB

reactivity.md

File metadata and controls

395 lines (312 loc) · 15.3 KB

🦉 Reactivity 🦉

Content

Introduction

Reactivity is a big topic in javascript frameworks. The goal is to provide a simple way to manipulate state, in such a way that the interface updates automatically according to state changes, and to do so in a performant manner.

To this end, Owl provides a proxy-based reactivity system, based on the reactive primitive. The reactive function takes an object as a first argument, and an optional callback as its second argument, it returns a proxy of the object. This proxy tracks what properties are read through the proxy, and calls the provided callback whenever one of these properties is changed through any reactive version of the same object. It does so in depth, by returning reactive versions of the subobjects when they are read.

useState

While the reactive primitive is very powerful, its usage in components follow a very standard pattern: components want to be rerendered when part of the state which they depend on for rendering changes. To this end, owl provides a standard hook: useState. To put it simply, this hook simply calls reactive with the provided object, and the current component's render function as its callback. This will cause it to rerender whenever any part of the state object that has been read by this component is modified.

Here is a simple example of how useState can be used:

class Counter extends Component {
  static template = xml`
    <div t-on-click="() => this.state.value++">
      <t t-esc="state.value"/>
    </div>`;

  setup() {
    this.state = useState({ value: 0 });
  }
}

This component reads state.value when it renders, subscribing it to changes to that key. Whenever the value changes, Owl will update the component. Note that there is nothing special about the state property, you can name your state variables whatever you want, and you can have multiple of them on the same component if it makes sense to do so. This also allows useState to be used in custom hooks that may require state that is specific to that hook.

Reactive props

Since version 2.0, Owl renders are no longer "deep" by default: a component is only rerendered by its parent if its props have changed (using a simple equality test). What if the contents of a props have changed in a deeper property? If that prop is reactive, owl will rerender the child components that need to be updated automatically, and only those components, it does so by reobserving reactive objects passed as props to components. Consider the following example:

class Counter extends Component {
  static template = xml`
    <div t-on-click="() => props.state.value++">
      <t t-esc="props.state.value"/>
    </div>`;
}

class Parent extends Component {
  static template = xml`
    <Counter state="this.state"/>
    <button t-on-click="() => this.state.value = 0">Reset counter</button>
    <button t-on-click="() => this.state.test++" t-esc="this.state.test"/>`;

  setup() {
    this.state = useState({ value: 0, test: 1 });
  }
}

When clicking on the counter button, only the Counter rerenders, because the Parent has never read the "value" key in the state. When clicking on the "Reset Counter" button, the same thing happens: only the Counter component rerenders. What matters is not where the state is updated, but which parts of the state are updated, and which components depend on them. This is achieved by Owl by automatically calling useState on reactive objects passed as props to a child component.

When clicking on the last button, the parent is rerendered, but the child does not care about the test key: it has not read it. The props that we give it (this.state) have also not changed, as such, the parent updates but the child doesn't.

For most day-to-day operations, useState should cover all of your needs. If you are curious about more advanced use cases and technical details, read on.

Debugging subscriptions

Owl provides a way to show which reactive objects and keys a component is subscribed to: you can look at component.__owl__.subscriptions. Note that this is on the internal __owl__ field, and should not be used in any type of production code as the name of this property or any of its properties or methods are subject to change at any point, even in stable versions of Owl, and may become available only in debug mode in the future.

reactive

The reactive function is the basic reactivity primitive. It takes an object or an array as first argument, and optionally, a function as the second argument. The function is called whenever any tracked value is updated.

const obj = reactive({ a: 1 }, () => console.log("changed"));

obj.a = 2; // does not log anything: the 'a' key has not been read yet
console.log(obj.a); // logs 2 and reads the 'a' key => it is now tracked
obj.a = 3; // logs 'changed' because we updated a tracked value

An important property of reactive objects is that they can be reobserved: this will create an independent proxy that tracks another set of keys:

const obj1 = reactive({ a: 1, b: 2 }, () => console.log("observer 1"));
const obj2 = reactive(obj1, () => console.log("observer 2"));

console.log(obj1.a); // logs 1, and reads the 'a' key => it is now tracked by observer 1
console.log(obj2.b); // logs 2, and 'b' is now tracked by observer 2
obj2.a = 3; // only logs 'observer1', because observer2 does not track a
obj2.b = 3; // only logs 'observer2', because observer1 does not track b
console.log(obj2.a, obj1.b); // logs 3 and 3, while the object is observed independently, it is still a single object

Because useState returns a normal reactive object, it is possible to call reactive on the result of a useState to observe changes to that object while outside the context of a component, or to call useState on reactive objects created outside of components. In those cases, one needs to be careful with regards to the lifetime of those reactive objects, as holding references to these objects may prevent garbage collection of the component and its data even if Owl has destroyed it.

Subscriptions are ephemereal

Subscription to state changes are ephemereal, whenever an observer is notified that a state object has changed, all of its subscriptions are cleared, meaning that if it still cares about it, it should read the properties it cares about again. For example:

const obj = reactive({ a: 1 }, () => console.log("observer called"));

console.log(obj.a); // logs 1, and reads the 'a' key => it is now tracked by the observer
obj.a = 3; // logs 'observer1' and clears the subscriptions of the observer
obj.a = 4; // doesn't log anything, the key is no longer observed

This may seem counter-intuitive, but it makes perfect sense in the context of components:

class DoubleCounter extends Component {
  static template = xml`
    <t t-esc="'selected: ' + state.selected + ', value: ' + state[state.selected]"/>
    <button t-on-click="() => this.state.count1++">increment count 1</button>
    <button t-on-click="() => this.state.count2++">increment count 2</button>
    <button t-on-click="changeCounter">Switch counter</button>
  `;

  setup() {
    this.state = useState({ selected: "count1", count1: 0, count2: 0 });
  }

  changeCounter() {
    this.state.selected = this.state.selected === "count1" ? "count2" : "count1";
  }
}

In this component, if we increment the value of the second counter, the component will not rerender, which makes sense as rerendering will have no effect, as the second counter is not displayed. If we toggle the component to display the second counter, we now no longer want the component to rerender when the value of the first counter changes, and this is what happens: a component only rerenders when there are changes to pieces of state that have been read during or after the previous render. If a piece of state has not been read in the last render, we know that its value won't influence the rendered output, and so we can ignore it.

reactive Map and Set

The reactivity system has special support built-in for the standard container types Map and Set. They behave like one would expect: reading a key subscribes the observer to that key, adding or removing an item to them notifies observers that have used any of the iterators on that reactive object, such as .entries() or .keys(), likewise with clearing them.

Escape hatches

Sometimes, it is desirable to bypass the reactivity system. Creating proxies when interacting with reactive objects is expensive, and while on the whole, the performance benefit that we get by rerendering only the parts of the interface that need it outweighs that cost, in some cases, we want to be able to opt out of creating them in the first place. This is the purpose of markRaw:

markRaw

Marks an object so that it is ignored by the reactivity system, meaning that if this object is ever part of a reactive object, it will be returned as is, and no keys in that object will be observed.

const someObject = markRaw({ b: 1 });
const state = useState({
  a: 1,
  obj: someObject,
});
console.log(state.obj.b); // attempt to subscribe to the "b" key in someObject
state.obj.b = 2; // No rerender will occur here
console.log(someObject === state.obj); // true

This is useful in some rare cases. One such example would be if you want to use an array of objects that is potentially large to render a list, but those objects are known to be immutable:

this.items = useState([
  { label: "some text", value: 42 },
  // ... 1000 total objects
]);

in the template:

<t t-foreach="items" t-as="item" t-key="item.label" t-esc="item.label + item.value"/>

Here, on every render, we go and read one thousand keys from a reactive object, which causes one thousand reactive objects to be created. If we know that the content of these objects cannot change, this is wasted work. If instead all of these objects are marked as raw, we avoid all of this work while keeping the ability to lean on the reactivity to track the presence and identity of these objects:

this.items = useState([
  markRaw({ label: "some text", value: 42 }),
  // ... 1000 total objects
]);

However, use this function with caution: this is an escape hatch from the reactivity system, and as such, using it may cause subtle and unintended issues! For example:

// This will cause a rerender
this.items.push(markRaw({ label: "another label", value: 1337 }));

// THIS WILL NOT CAUSE A RENDER!
this.items[17].value = 3;
// The UI is now desynced from component's state until the next render caused by something else

In short: only use markRaw if your application is slowing down noticeably and profiling reveals that a lot of time is spent creating useless reactive objects.

toRaw

While markRaw marks an object so that it is never made reactive, toRaw takes an object and returns the underlying non-reactive object. It can be useful in some niche cases. In particular, because the reactivity system returns a proxy, the returned object does not compare equal to the original object:

const obj = {};
const reactiveObj = reactive(obj);
console.log(obj === reactiveObj); // false
console.log(obj === toRaw(reactiveObj)); // true

It can also be useful during debugging, as unfolding proxies recursively in debuggers can be confusing.

Advanced usage

The following is a collection of small snippets that leverage the reactivity system in "non-standard" ways to help you understand its power and where using it might make your code simpler.

Notification manager

Showing notifications is a pretty common need in web applications, you may want to show a notification from any other component within the application, and the notifications should stack on top of one another regardless of which component spawned them, here is how we can leverage the reactivity to accomplish this:

let notificationId = 1;
const notifications = reactive({});
class NotificationContainer extends Component {
  static template = xml`
    <t t-foreach="notifications" t-as="notification" t-key="notification_key" t-esc="notification"/>
  `;
  setup() {
    this.notifications = useState(notifications);
  }
}

export function addNotification(label) {
  const id = notificationId++;
  notifications[id] = label;
  return () => {
    delete notifications[id];
  };
}

Here, the notifications variable is a reactive object. Notice how we didn't give reactive a callback: this is because in this case, all we care about is that adding or removing notifications in the addNotification function goes through the reactivity system. The NotificationContainer component reobserves this object with useState, and is updated whenever notifications are added or removed.

Store

Centralizing application state is a pretty common want/need in web applications. Because of the way the reactivity system works, you can treat any reactive object as a store, and if you call useState on it, components automatically observe only the part of the store that they're interested in:

export const store = reactive({
  list: [],
  add(item) {
    this.list.push(item);
  },
});

export function useStore() {
  return useState(store);
}

In any component:

import { useStore } from "./store";

class List extends Component {
  static template = xml`
    <t t-foreach="store.list" t-as="item" t-key="item" t-esc="item"/>
  `;
  setup() {
    this.store = useStore();
  }
}

Anywhere in the application:

import { store } from "./store";
// Will cause any instance of the List component in the app to update
store.add("New list item!");

Notice how we can make objects with methods into reactive objects, and when these methods are used to mutate the store contents, it works as expected. And while stores are generally one-off objects, it is entirely possible to make class instances reactive:

class Store {
  list = [];
  add(item) {
    this.list.push(item);
  }
}
// Essentially equivalent to the previous code
export const store = reactive(new Store());

Which can be useful to unit test the class separately.

Local storage synchronization

Sometimes, you want to persist some state accross reloads, you can do this by storing it in the localStorage, but what if you want to update the localStorage item every time the state changes, so that you don't have to manually synchronize the states? Well, you can use the reactivity system to write a custom hook that will do that for you:

function useStoredState(key, initialState) {
  const state = JSON.parse(localStorage.getItem(key)) || initialState;
  const store = (obj) => localStorage.setItem(key, JSON.stringify(obj));
  const reactiveState = reactive(state, () => store(reactiveState));
  store(reactiveState);
  return useState(state);
}

class MyComponent extends Component {
  setup() {
    this.state = useStoredState("MyComponent.state", { value: 1 });
  }
}

One important thing to notice is that both times we call store, we call it with reactiveState, not state: we need store to read the keys through a reactive object for it to correctly subscribe to state changes. Notice also that we call store the first time by hand, as otherwise it will not be subscribed to anything, and no amount of change in the object will cause the reactive callback to be invoked.