adobe / twist

State-management library with data binding and observables
Apache License 2.0
23 stars 10 forks source link

Documentation for persistence of stores #14

Open lanther opened 6 years ago

lanther commented 6 years ago

While twist makes it easy to persist stores, through having toJSON() and fromJSON() methods, it's easy to run into pitfalls due to persistence having side effects. We need to collect some best practices, and if possible prevent some of the most common issues.

Pitfall 1: Overloading the constructor/initialization

If you want a store to load its data from local storage, the following might seem like a good idea:

@Store
class MyStore() {
    @State.byVal data;

    constructor(initialState) {
        // You really don't want to do this:
        super(JSON.parse(localStorage.getItem('myData')));
    }
}

However, this is bad (other than the lack of try/catch) for two reasons:

  1. It won't work if MyStore is nested inside another store, due to the way @State.byRef works - it first creates a new MyStore and then calls fromJSON() to initialize it. So your initial data will be overriden by whatever you passed into the main store.

  2. Even if it did work, it would violate the principle of "determinism" - all actions should be deterministic, so that you can replay/debug them. If they're not deterministic, you'll get a different answer each time you run it (or if you run it in different contexts), which makes it really hard to debug. It's the same reason why reducers in redux must be pure functions.

Instead, there are two "correct" ways to initialize your store from local storage data:

@Store
class MyStore() {
    @State.byVal data;

    @Action _LOAD(data) {
        try {
            this.fromJSON(JSON.parse(data));
        }
        catch(e) {
            console.error('Invalid data - aborted load', e);
        }
    }

    @Action({ async: true }) LOAD_FROM_STORAGE() {
        this.dispatch('_LOAD', localStorage.getItem('myData'));
    }
}

Now, in the initialization code for the store, you'll do something like:

this.scope.store = new MainStore(INITIAL_DATA);
this.scope.store.settings.dispatch('LOAD_FROM_STORAGE');

Note that since LOAD_FROM_STORAGE is asynchronous, you need to dispatch it directly on the store in question.

Pitfall 2: Non-deterministic actions, and actions with side effects

You might be wondering why the above example has two actions, rather than just one. It's again because of the principle that actions should be deterministic. Think about what would happen if you just wrote:

@Store
class MyStore() {
    @State.byVal data;

    @Action LOAD_FROM_STORAGE() {
        try {
            this.fromJSON(JSON.parse(localStorage.getItem('myData')));
        }
        catch(e) {
            console.error('Invalid data - aborted load', e);
        }
    }
}

When you run this through redux devtools, you'll just see a single action "LOAD_FROM_STORAGE" in the logs. But if you were to undo, and then replay this action again, you'd get a different answer because local storage might have changed.

For this reason, you always want to separate anything that's non-deterministic, or has side effects, into an asynchronous action, and keep the actions deterministic.

The same thing applies when you save the store to local storage:

@Action SET_DATA(value) {
    this.data = value;
    // Don't do this inside a synchronous action:
    localStorage.setItem('myData', value);
}

Here, the action is deterministic, but it has side effects (saving to local storage). This will work fine with redux-devtools, because of the determinism, but it'll still cause you trouble if you undo/replay actions while debugging, because this will change the local storage as well. That could lead to you scratching your head... so just keep life simple, and restrict the side effects of actions to the store and its children.

As before, if we split it into a synchronous/asynchronous action pair, all is good:

@Action _SET_DATA(value) {
    this.data = value;
}

@Action({ async: true }) SET_DATA(value) {
    this.dispatch('_SET_DATA', value);
    localStorage.setItem('myData', value);
}

Possible API Extensions

It's worth looking into adding functionality to Twist, to manage persistence layers. This would need to know:

  1. Where is the persistence? (localStorage? If so, what key etc).
  2. What is persisted? - is this at the store level, or state property level?
  3. When is it persisted? - we can't do this automatically inside normal actions, because of the side effect issue. Maybe there needs to be a way for distinguishing between normal invocation of actions versus replayed actions from devtools.
  4. How is it restored?

The neatest solution might be a subclass of Store - e.g. we can have one for LocalStorage, but there could be other types of persistent stores. If so, (1) could be options on the decorator (@LocalStorageStore({ ... })) and (4) could be a new method (e.g. .load()), that knows to recurse down the store tree.

(3) is a tricky one, and I feel we need to think more deeply about this.

lanther commented 6 years ago

@kerrishotts @chnicoloso @mcav FYI

kerrishotts commented 6 years ago

Awesome stuff here, and definitely something we need to document in the docs (I'll work on that next week).

A few quick thoughts re: persistence:

  1. Ideally this would be flexible enough to support any sort of persistence provider. So, not just localStorage, but nosql backends, for example.
  2. Maybe stores delegate persistence to persistence providers? Something like @Store{persistenceProvider: LocalStoragePersistence} or similar... just a thought that popped in my head...

I'll think about this stuff some more tomorrow when my brain cells are a bit more awake... ;-)

chnicoloso commented 6 years ago

This is fantastic and very clear. Thank you Michael!