Update Store based on previous value
rubixibuc opened this issue · 10 comments
Hi!
Regarding updates to a Store in quick succession, how do we safely update the value based on the previous state?
I'm noticing that the value retrieved from the Store factory is not the immediately previous value since the setter is async. I wasn't able to find this in the docs, I might be missing it though.
Thank you!
Hi @rubixibuc! Can you provide a simplified example of what you want to achieve? I'm not sure what value you mean, some outside of the store model?
If you mean something with displaying data, there are guards, which check the current state of the model. If you update already fetched model, you should use combination of ready and pending guards:
store.ready(model) && !store.pending(model)
This is intentional behavior, as the store updates the model asynchronously, while still returning the last cached value. It's powerfull feature, as you don't have to worry about empty state of data when updated.
No problem!
So based on what I'm seeing both of these calls see the original value of the store instead of the one from the previous update.
import { store } from "hybrids";
const Settings = {
count: 0,
};
store.set(Settings, { count: store.get(Settings).count + 1 });
store.set(Settings, { count: store.get(Settings).count + 1 });
setTimeout(() => console.log(store.get(Settings).count), 2000);
This outputs "1". I understand the sets are async, but is there a way to call them in sequence based on the previous value from other calls?
My use case involves the store factory and multiple components that update the store, but this example exemplifies the question cleaner.
What you need is store.resolve()
- https://hybrids.js.org/#/store/usage?id=storeresolve.
It waits until "at the moment" model is not updated, even if there is a chain of updates, by store.set().then(() => store.set())..
. It returns the promise, so you can use it like this:
async function updateModel(host) {
const model = await store.resolve(host.model);
store.set(model, { value: model.value + 1 });
}
Got it, ty!
I tested the change, but I'm still seeing the race condition.
here is my exact code
healthy: {
get: ({ status }) => status === 200,
observe: async ({ projectsState }, value) => {
const model = await store.resolve(projectsState);
console.log(model.unhealthyCount);
await store.set(model, {
unhealthyCount: model.unhealthyCount + (value ? -1 : 1),
});
},
},
projectsState: store(ProjectsState),
I have several components updating their healthy status on load after performing a fetch. Initially every console.log
outputs "0"
It's clear to me why this doesn't work, I just don't know what to replace it with
I had to look into the source to be sure ;) The store protects from setting a model twice at the same time. It checks if the model is pending, and schedules the next update. However, you can't avoid resolving the model, where your line with await store.set()
will get the same model instance, so the value will be overwritten with the same old value. It's technically impossible. The store still works, but you should not rely on the previous value.
Generally, the store wasn't designed to support that case. The models can store data, but they should be as stateless as possible, so the current value should not be related to the previous one.
I'm not seeing a bigger picture of your problem, but what would suggest is updating the unhealthyCount
in one place, for example in some root level element. You can use children()
factory to get statuses of those elements, and wherever they change, you can update the state value:
import { define, children, store } from "hybrids";
define({
tag: "some-parent",
components: children(MyChild),
unhealthy: {
get: ({ components }) => components.reduce((acc, { healthy }) => acc - Number(healthy), components.length),
observe(host, value) {
store.set(ProjectsState, { unhealthyCount: value });
},
},
...
);
I understand.
The children in my case are in the shadow dom not in the light dom.
So my components would look something like
childComponents: children(ChildComponent),
someArray: void 0,
render: () => html`${someArray.map((child) => html`<child-component child="${child}"></child-component>`)}`
in this case childComponents
always seems to return 0.
sorry for all the issues.
Is there a way I can solve it using this pattern? I'm not sure using slots makes sense in this case since the parent excepts an array of children. I think I would have to create a third component to wrap both in the other case.
The children factory indeed only scans the light DOM (it is also a technical limitation to the MutationObserver API). In this case, you can use content
property (to render in light DOM; if you don't use styling and slots, it is recommended), or use parent()
factory (which go through the shadow DOM boundary). In this case, your parent should have some count
property, chich children when mounted (and fetched those data) update the value if they are healthy. The property set is synchronous, so the value is always up to date:
const Parent = define({
tag: "my-parent",
healthy: 0,
render: () => html`...`.
});
define({
tag: "my-children",
...,
parent: parent(Parent),
healthy: {
get: ({ status }) => status === 200.
observe({ parent }, healthy) {
parent.healthy += healthy ? 1 : - 1;
},
});
After a while, I found a similar, but a more clear solution, using DOM events, so the parent and children don't have to know implementation details of each other.
- In your child element, in
observe()
method dispatch custom event with the healthy status (remember aboutbubbles: true
). - In your parent element render method, where you put your components, listen to this event by
<my-children onstatuschange=${doSomething}></my-children>
.
It can still be a value in the parent, which is synchronously updated.
I think this is how I will do it.
Thank you for working through it!
I understand the store use case better now as well :)
Cool, let me know if it worked out, and of course re-open the issue if you have any question about the subject.