beekai-oss / little-state-machine

📠 React custom hook for persist state management
https://lrz5wloklm.csb.app/
MIT License
1.47k stars 53 forks source link

Changing schema of state - guidance on versioning/migrations? #147

Open kylekampy opened 1 year ago

kylekampy commented 1 year ago

Hello! Thanks so much for creating this utility! It is so useful and I very much appreciate it.

I ran into a small problem, which is definitely self-inflicted I just didn't realize I was going to encounter it.

I've got state persisted to local storage. There are various top-level keys within the state that it's writing to storage. I'd like to add an additional key. As an example, currently I have:

createStore(
    {
      thing1: getThing1InitialState(), // returns some object with default values
      thing2: getThing2InitialState(),
    },
);

And I want to add a new thing. My code now looks like:

createStore(
    {
      thing1: getThing1InitialState(),
      thing2: getThing2InitialState(),
      thing3: getThing3InitialState(), // <-- new
    },
);

Then my components have:

const { state } = useStateMachine();

const propertyOfThing3 = state.thing3.foo; // unexpected -- cannot access property 'foo' of undefined

Because I already had a JSON bit of state written to local storage before releasing a version with thing3, the JSON blob is parsed and used as state on app load through the eventual calls to updateStore https://github.com/beekai-oss/little-state-machine/blob/master/src/logic/storeFactory.ts#L20. Because that older version of state didn't have thing3, it's just not included in there, and there doesn't appear to be logic in little-state-machine to "fill in the blanks" with default values. Then state.thing3 is undefined and things crash.

The way I solved this for myself was in assuming any of the pieces of state could be undefined. I have my GlobalState type defined like:

declare module 'little-state-machine' {
  interface GlobalState {
    thing1?: Thing1Type;
    thing2?: Thing2Type;
    thing3?: Thing3Type;
  }
}

And then my actions all take special precaution to assume they may be working with nested state that isn't yet defined:

export function updateThing3Foo(state: GlobalState, payload: {
  newFoo: string,
}): GlobalState {
  return {
    ...state,
    thing3: {
      ...getThing3InitialState(), // <-- in case we've added new properties, or this whole state hasn't yet been written/parsed
      ...state.thing3, // <-- in case there was already state, and we don't want to lose any of the other properties that were here
      foo: payload.newFoo,
    },
  };
}

I figured I'd write this out in case anyone else has also encountered this problem and is looking for some kind of solution that seems to work. But I would also love to hear from others on where I went wrong. How are others adding to their state over time without crashes on release? Does anyone have a migration or versioning strategy they use to keep their typing of state and actual state consistent?

Thanks so much!