Skip to content

Observable

You can put anything in an observable: primitives, deeply nested objects, arrays, functions, etc… Observables work just like normal objects so you can interact with them without any extra complication. Just call get() to get a value and set(...) to modify it.

import { observable } from "@legendapp/state";

const state$ = observable({ text: "hello" });

console.log(state$.get());
// { text: 'hello' }

An observable’s constructor can include functions or computed/proxy observables.

import { computed, observable } from "@legendapp/state";

const state$ = observable({
  fname: "hello",
  lname: "there",
  setName: (name: string) => {
    // Create Actions by just adding a function
    const [fname, lname] = name.split(name);
    state$.assign({
      fname,
      lname,
    });
  },
  fullname: computed((): Observable<string> => {
    // Set up computed observables within your state object
    // or if you prefer them elsewhere that's cool too 🤟
    return `${state$.fname.get()} ${state$.lname.get()}`;
  }),
});

console.log(state$.fullname.get());
// hello there

Note: In TypeScript, you need to type the return value of nested computed functions or the observable will have a type of any.

Observable functions

get()

Observables use Proxy to expose observable functions and track changes, so an observable is a Proxy pointing to the actual data. You can use get() to get the actual value of any observable.

const profile = { name: "Test user" };
const state$ = observable({ profile: profile, test: 0 });

// The raw value is unchanged
state$.profile.get(); // { name: 'Test user' }
state$.profile === profile; // ❌ false. The observable is not strictly equal to profile.
state$.profile.get() === profile; // ✅ true. The raw data is exactly what was set.

Accessing properties through the observable will create a Proxy for every property accessed, but it will not do that while accessing the raw data. So you may want to retrieve the raw data before doing expensive computations that do not need to notify.

const state$ = observable({ data: someHugeThing });
const { data } = state$.get();

// Nothing special happens when working with the raw data
processData(data);

Calling get() within a tracking context tracks the observable automatically. You can change that behavior with a parameter true to track only when keys are added/removed. See observing contexts for more details.

state$.get(true); // Create a shallow listener

peek()

peek() returns the raw value in the same way as get(), but it does not automatically track it. Use this when you don’t want the component/observing context to update when the value changes.

set()

You can use set() to modify the observable, at any path within it. You can even set() on a node that is currently undefined, and it will fill in the object tree to make it work.

const state$ = observable({ text: "hi" });

// Set directly
state$.text.set("hello there");

// Set with a function relative to previous value
state$.text.set((prev) => prev + " there");

// Set will automatically fill out objects that were undefined
state$.otherKey.otherProp.set("hi");

assign()

Assign is a shallow operation matching Object.assign. If you want a deep merge, see mergeIntoObservable.

const state$ = observable({ text: "hi" });

// Assign
state$.assign({ text: "hi2" });

delete()

Observables provide a delete function to delete a key from an object.

const state$ = observable({ text: "hi" });

// Delete text
state$.text.delete();

// Set the whole value to undefined
state$.delete();

Observable Types

Computed

computed takes a function that accesses other observables, and automatically tracks the observables accessed while computing. So you can return a computed value based on one or multiple observables, and it will update whenever one of them changes.

The compute function is lazy so it won’t run until you get() the value the first time.

const state$ = observable({ test: 10, test2: 20 });

// Returning a function makes it computed from other observables
const computed$ = computed(() => state$.test.get() + state$.test2.get());
// computed$.get() === 30

state$.test.set(5);
// computed$.get() === 25

Two-way computed

computed has an optional set parameter to run when setting the value. This lets you pass state changes onto the target observables, so the computed observable is bound to the targets in both directions. Without a set parameter, a one-way computed is read-only.

const selected$ = observable([false, false, false]);
const selectedAll$ = computed(
  // selectedAll is true when every element is selected
  () => selected$.every((val$) => val$.get()),

  // setting selectedAll sets the value of every element
  (value) => selected$.forEach((val$) => val$.set(value))
);

selectedAll$.set(true);
// selected.get() === [true, true, true]

Linked observables

If you return an observable in computed, it will create a two-way link to the target observable. Any observable operations and listeners on the link will work the same as interacting with the original target.

const state$ = observable({
  items: ["hi", "there", "hello"],
  selectedIndex: 0,
  selectedItem: computed(() => state$.items[state$.selectedIndex.get()]),
});

state$.selectedItem.get() === "hi"; // true

state$.selectedIndex.set(2);

state$.selectedItem.get() === "hello"; // true

event

event works like an observable without a value. You can listen for changes as usual, and dispatch it manually whenever you want. This can be useful for simple events with no value, like onClosed.

import { event } from "@legendapp/state"

const onClosed = event()

// Simply pass a callback to the `on` function
onClosed.on(() => { ... })

// Or use it with 'onChange' like other observables
onClosed.onChange(() => { ... })

// Dispatch the event to call listeners
onClosed.fire()

proxy

proxy creates an observable object that is indexable by a string key, and creates a computed observable for each key.

import { observable, proxy } from "@legendapp/state"

const state$ = observable({
    selector: 'text',
    items: { test1: { text: 'hi', othertext: 'bye' }, test2: { text: 'hello', othertext: 'goodbye' } },
    itemText: proxy((key) => {
        return obs.items[key][obs.selector.get()];
    }),
});

// Now these reference the same thing:
state$.items.test1.text.get()
state$.itemText.test1.get()

Notes

Safety

Observables are safe so that you cannot directly assign to them, which prevents accidentally overwriting state or accidentally assigning huge objects into an observable.

const state$ = observable({ text: "hello", num: 10, obj: {} }, /*safe*/ true);

state$.text = "hi";
// ❌ Can't set directly

state$.text.set("hi");
// ✅ Calling set on a primitive works.

state$ = {};
// ❌ Error. This would delete the observable.

state$.obj = {};
// ❌ Error. Cannot assign to objects directly.

state$.set({ text: "hi", num: 20 });
// ✅ Calling set on an object works.

state$.assign({ text: "hello there" });
// ✅ Calling assign on an object works.

state$.text.assign({ value: "hello there" });
// ❌ Error. Cannot call assign on a primitive.

undefined

Because observables track nodes by path and not the underlying data, an observable points to a path within an object regardless of its actual value. So it is perfectly fine to access observables when they are currently undefined in the object.

You could to do this to set up a listener to a field whenever it becomes available.

const state = observable({ user: undefined });

when(state.user.uid, (uid) => {
  // Handle login
});

Or you could set a value inside an undefined object, and it will fill out the object tree to make it work.

const state = observable({ user: undefined });

observe(() => {
  // This will be undefined until the full user profile is set
  console.log(`Name: ${state.user.profile.name.get()}`);
});

state.user.profile.name.set("Annyong");

// state == { user: { profile: { name: 'Annyong' } } }

Arrays

Observable arrays have all of the normal array functions as you’d expect, but some are modified for observables.

All looping functions set up shallow tracking automatically, as well as provide the observable in the callback. This includes:

  • every
  • filter
  • find
  • findIndex
  • forEach
  • includes
  • join
  • map
  • some

Additionally, filter returns an array of observables and find returns an observable (or undefined).

If you don’t want this extra observable behavior, get() or peek() the observable to get the raw array to act on.