Skip to content

.watch calls are (not) weird

Sometimes, you can notice a weird behavior in your code if you use .watch to track Store changes. Let us explain what is going on and how to deal with it.

Effector's main mantra

Summary

.watch method immediately executes callback after module execution with the current value of the Store.

Effector is based on the idea of explicit initialization. It means that module execution should not produce any side effects. It is a good practice because it allows you to control the order of execution and avoid unexpected behavior. This mantra leads us to the idea of explicit start of the app.

However, it is one exception to this rule: callback in .watch call on Store is executed immediately after the store is created with a current value. This behavior is not quite obvious, but it is introduced on purpose.

Why?

Effector introduced this behavior to be compatible with default behavior of Redux on the early stages of development. Also, it allows using Effector Stores in Svelte as its native stores without any additional compatibility layers.

It is not a case anymore, but we still keep this behavior for historical reasons.

The problem and solutions

Now, let us consider the following example:

ts
const $store = createStore('original value');

$store.watch((value) => {
  console.log(value);
});

const scope = fork({
  values: [[$store, 'forked value']],
});

// -> 'original value'

In this example, console will print only "original value" since fork call does not produce any side effects.

Even if we change order of calls, it will not change the behavior:

ts
const $store = createStore('original value');

const scope = fork({
  values: [[$store, 'forked value']],
});

$store.watch((value) => {
  console.log(value);
});

// -> 'original value'

It could be confusing, but it is not a bug. First .watch call executes only with current value of the Store outside of Scope. In real-world applications, it means that you probably should not use .watch.

Current value?

Actually, yes. Callback executes with the current value of the Store outside of Scope. It means, you can change value of the Store before .watch call and it will be printed in the console:

ts
const $store = createStore('original value');

$store.setState('something new');

$store.watch((value) => {
  console.log(value);
});

// -> 'something new'

However, it is a dangerous way, and you have to avoid it in application code.

In general .watch could be useful for debugging purposes and as a way to track changes in Store and react somehow. Since, it is not a good idea to use it in the production code, let us consider some alternatives.

Debug

Effector's ecosystem provides a way more powerful tool for debugging: patronum/debug. It correctly works with Fork API and has a lot of other useful features.

First, install it as a dependency:

sh
pnpm install patronum
sh
yarn add patronum
sh
npm install patronum

Then, mark Store with debug method and register Scope with debug.registerScope method:

ts
import { createStore, fork } from 'effector';
import { debug } from 'patronum';

const $store = createStore('original value');

debug($store);

const scope = fork({
  values: [[$store, 'forked value']],
});

debug.registerScope(scope, { name: 'myAppScope' });

// -> [store] $store [getState] original value
// -> [store] (scope: myAppScope) $store [getState] forked value
ts
import { createStore, fork } from 'effector';

const $store = createStore('original value');

$store.watch((value) => console.log('[store] $store', value));

const scope = fork({
  values: [[$store, 'forked value']],
});

// -> [store] $store original value

That is it! Furthermore, you can use debug method not only to debug value of Store but also for track execution of other units like Event or Effect, for trace chain of calls and so on. For more details, please, check patronum/debug documentation.

TIP

Do not forget to remove debug calls from the production code. To ensure that, you can use effector/no-patronum-debug rule for ESLint.

React on changes

If you need to react on changes in Store, you can use .updates property. It is an Event that emits new values of the Store on each update. With a combination of sample and Effect it allows you to create side effects on changes in Store in a declarative and robust way.

ts
import {
  createEffect,
  createStore,
  createEvent,
  sample,
  fork,
  allSettled,
} from 'effector';

const someSideEffectFx = createEffect((storeValue) => {
  console.log('side effect with ', storeValue);
});

const $store = createStore('original value');

const appInited = createEvent();

sample({
  clock: [appInited, $store.updates],
  source: $store,
  target: someSideEffectFx,
});

const scope = fork({
  values: [[$store, 'forked value']],
});

allSettled(appInited, { scope });

// -> side effect with forked value
ts
import { createStore, fork } from 'effector';

const $store = createStore('original value');

$store.watch((value) => console.log('side effect with ', value));

const scope = fork({
  values: [[$store, 'forked value']],
});

// -> side effect with original value

TIP

Since, Effector is based on idea of explicit triggers, in this example we use explicit start of the app.

This approach not only solve problems that mentioned above but also increases code readability and maintainability. For example, real-world side effects can sometimes fail, and you need to handle errors. With .watch approach, you need to handle errors in each callback. With Effect approach, you can handle errors in seamless declarative way, because Effect has a built-in property .fail which is an Event that emits on each failure.

Summary

  • Do not use .watch for debug - use patronum/debug instead
  • Do not use .watch for logic and side effects - use Effects instead

Released under the MIT License.