Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Store Update - merges current and previous state values. #661

Open
field123 opened this issue Apr 25, 2021 · 7 comments · May be fixed by #663
Open

Store Update - merges current and previous state values. #661

field123 opened this issue Apr 25, 2021 · 7 comments · May be fixed by #663

Comments

@field123
Copy link
Contributor

I'm submitting a...


[ ] Regression (a behavior that used to work and stopped working in a new release)
[x] Bug report  
[ ] Performance issue
[ ] Feature request
[ ] Documentation issue or request
[x] Support request
[ ] Other... Please describe:

Current behavior

When calling store.update only the specified properties are being replaced.

If I have two possible states I want to represent in the Todos store

export type TodosState = TodosBasicState | TodosAltState;

interface TodosBasicState {
  _tag: "todos-basic-state";
  uniqueToBasicObject: {
    info: string;
  };
  basicInfo: string;
}

interface TodosAltState {
  _tag: "todos-alt-state";
  altInfo: string;
}

I intialize my todos state to a TodosBasicState

const initialState: TodosState = {
  _tag: "todos-basic-state",
  uniqueToBasicObject: { info: "test" },
  basicInfo: "Basic Info"
};

When I update the state using the following method I get a polluted state object that is a combination of the old state and the new values.

this.todosStore.update((state) => ({
      _tag: "todos-alt-state",
      altInfo: "This is some alt info.",
    }));

State object as json before update:

{
  "_tag": "todos-basic-state",
  "uniqueToBasicObject": {
    "info": "test"
  },
  "basicInfo": "Basic Info"
}

State object as json after update:

{
  "_tag": "todos-alt-state",
  "uniqueToBasicObject": {
    "info": "test"
  },
  "basicInfo": "Basic Info",
  "altInfo": "This is some alt info."
}

As you can see the the _tag and altInfo properties have been updated as expected but the rest of the state has not been removed from the previously.

The following line is the cause in store.ts. The current and new state are both being combined in a spread operation. I'm sure this is being done for a reason I'm ignorant of but is this side effect intentional?

https://github.com/datorama/akita/blob/721243c814b8835a0bec113a686c5a1400dc2f2a/libs/akita/src/lib/store.ts#L256

Expected behavior

I would expect that when calling the update method I get only the specified properties in the state and all old properites are dropped.

Expect this:

this.todosStore.update((state) => ({
      _tag: "todos-alt-state",
      altInfo: "This is some alt info.",
    }));

to turn state from:

{
  "_tag": "todos-basic-state",
  "uniqueToBasicObject": {
    "info": "test"
  },
  "basicInfo": "Basic Info"
}

to this:

{
  "_tag": "todos-alt-state",
  "altInfo": "This is some alt info."
}

If I wanted to keep values from the previous state I would expect to have to include them in the update callback using a spread or explicitly.

e.g.

this.todosStore.update(state => ({
      ...state,
      _tag: "todos-alt-state",
      altInfo: "This is some alt info."
    }));

Minimal reproduction of the problem with instructions

https://stackblitz.com/edit/akita-todos-app-pyf2xv?file=src/app/todos/state/todos.query.ts

Click update to perform the above update method and click reset to reset the store back to the initial method.

What is the motivation / use case for changing the behavior?

I want to be able to update the state without previous state values polluting the new state. If there is a better way to achieve this any advise is welcome?

Environment


Angular version: X.Y.Z


Browser:
- [ x] Chrome (desktop) version XX
- [ x] Chrome (Android) version XX
- [ x] Chrome (iOS) version XX
- [ x] Firefox version XX
- [ x] Safari (desktop) version XX
- [ x] Safari (iOS) version XX
- [ x] IE version XX
- [ x] Edge version XX
@NetanelBasal
Copy link
Collaborator

The update method will merge the current state with the new one. You can use the setState method to override it.

@field123
Copy link
Contributor Author

That does do exactly what I needed, Thank you.

With the method being marked as @internal :

https://github.com/datorama/akita/blob/721243c814b8835a0bec113a686c5a1400dc2f2a/libs/akita/src/lib/store.ts#L177

Would you be open to making (or accepting a pull request for) a public version of this functionality available to address the use case I've outlined above. Something along the lines of:

set(stateOrCallback: Partial<S> | UpdateStateCallback<S>) {
    isDev() && setAction('Set');

    let newState;
    const currentState = this._value();
    if (isFunction(stateOrCallback)) {
      newState = isFunction(this._producerFn) ? this._producerFn(currentState, stateOrCallback) : stateOrCallback(currentState);
    } else {
      newState = stateOrCallback;
    }

    const withHook = this.akitaPreSet(currentState, newState as S);
    const resolved = isPlainObject(currentState) ? withHook : new (currentState as any).constructor(withHook);
    this._setState(resolved);
}

with a supporting hook:

// @internal
  akitaPreSet(_: Readonly<S>, nextState: Readonly<S>): S {
    return nextState;
}

Set may not be the best name for it maybe replace or something else?

@NetanelBasal
Copy link
Collaborator

I prefer to add options to the update method. update(object, { overwrite: true })

@field123
Copy link
Contributor Author

Something like:

update(stateOrCallback: Partial<S> | UpdateStateCallback<S>, options?: { override: boolean }) {
    isDev() && setAction('Update');

    let newState;
    const currentState = this._value();
    if (isFunction(stateOrCallback)) {
      newState = isFunction(this._producerFn) ? this._producerFn(currentState, stateOrCallback) : stateOrCallback(currentState);
    } else {
      newState = stateOrCallback;
    }

    newState = options?.override ? newState :  { ...currentState, ...newState };
    
    const withHook = this.akitaPreUpdate(currentState, newState as S);
    const resolved = isPlainObject(currentState) ? withHook : new (currentState as any).constructor(withHook);
    this._setState(resolved);
  }

The only concern I would have with boolean flags in an options object is adding conditional complexity to a function call e.g. what if I want different hook behaviour for this do we start doing something like this:

const withHook = options?.override ? this.akitaPreSet(currentState, newState as S) : this.akitaPreUpdate(currentState,  { ...currentState, ...newState } as S);

Maybe it would be better to have clean simple method signatures and abstract away some of the same repeated code.

update(stateOrCallback: Partial<S> | UpdateStateCallback<S>): void {
    isDev() && setAction('Update');
    const withHookFn = (curr: S, newS: S) => this.akitaPreUpdate(curr,  { ...curr, ...newS } as S);
    this._setState(this.sharedProcess(stateOrCallback, this._value(), withHookFn));
  }

  overwrite(stateOrCallback: Partial<S> | UpdateStateCallback<S>): void {
    isDev() && setAction('Overwrite');
    const withHookFn = (curr: S, newS: S) => this.akitaPreOverwrite(curr,  newS as S);
    this._setState(this.sharedProcess(stateOrCallback, this._value(), withHookFn));
  }

  sharedProcess<S>(stateOrCallback: Partial<S> | UpdateStateCallback<S>, currentState: S, withHookFn: (c: S, n: S) => S): S {
    const constructNewState = (x: Partial<S> | UpdateStateCallback<S>) => {
      if (isFunction(x)) {
        return isFunction(this._producerFn) ? this._producerFn(currentState, x) : x(currentState);
      } else {
        return x;
      }
    }
    const resolveFinalState = (currentState: S, withHook: S): S => {
      return isPlainObject(currentState) ? withHook : new (currentState as any).constructor(withHook);
    }
    
    const newState = constructNewState(stateOrCallback, currentState);
    const withHook = withHookFn(currentState, newState);
    return resolveFinalState(currentState, withHook);
  }

This would keep the public api transparent and explicit, removing any ambiguity?

@NetanelBasal
Copy link
Collaborator

NetanelBasal commented Apr 26, 2021

Ok, let's go with option two

@field123
Copy link
Contributor Author

field123 commented Apr 26, 2021

Ok, I'll put together a pull request later today.

field123 pushed a commit to field123/akita that referenced this issue Apr 26, 2021
Created a public method on the akita store that will completely replace the current state with the new provided state and not just update the value.

Closes salesforce#661
@field123 field123 linked a pull request Apr 26, 2021 that will close this issue
13 tasks
field123 pushed a commit to field123/akita that referenced this issue Apr 27, 2021
Renamed the new set method to overwrite in an effort to avoid conflicts with the entityStore method set.

Closes salesforce#663
PR salesforce#661
field123 pushed a commit to field123/akita that referenced this issue Apr 28, 2021
Removed redundant local functions in favour of inlining.

Simplified the documentation example for overwrite()

Closes salesforce#661
PR salesforce#663
@clark-stevenson
Copy link

clark-stevenson commented Sep 1, 2022

Hey guys!

I ran into this issue today which was unexpected. It would seem the PR became stale and given the passage of time, may no longer be relevant/required?

Would you still suggest using the internal _setState() as a work around for now?

Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants