Add getter api to @xstate/store in main#5191
Add getter api to @xstate/store in main#5191expelledboy wants to merge 5 commits intostatelyai:mainfrom
Conversation
🦋 Changeset detectedLatest commit: 02fa5ce The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
@davidkpiano I managed to resolve all the issue with the PR. Please review and let me know what you think. |
36cccf1 to
02fa5ce
Compare
|
Also, sorry for the delay, but can you explain the benefits of getters, over more explicit selectors? |
|
By incorporating Imagine our cart store has the following raw state:
import { createStore } from '@xstate/store';
const cartStore = createStore({
context: {
items: [
{ id: 1, name: 'Widget', price: 50, quantity: 2, discount: 0.10, taxRate: 0.08 },
{ id: 2, name: 'Gadget', price: 30, quantity: 1, discount: 0.05, taxRate: 0.08 }
],
shippingThreshold: 100,
baseShippingCost: 10
},
getters: {
subtotal: (ctx) =>
ctx.items.reduce((sum, item) => sum + item.price * item.quantity, 0),
// Total discount applied
totalDiscount: (ctx) =>
ctx.items.reduce(
(sum, item) => sum + item.price * item.quantity * item.discount,
0
),
// Tax calculated on the net amount (subtotal minus discount)
taxAmount: (ctx, getters) =>
(getters.subtotal - getters.totalDiscount) *
(ctx.items.length ? ctx.items[0].taxRate : 0),
// Shipping cost is waived if subtotal exceeds threshold
shippingCost: (ctx, getters) =>
getters.subtotal >= ctx.shippingThreshold ? 0 : ctx.baseShippingCost,
// Total amount to be paid
total: (ctx, getters) =>
getters.subtotal - getters.totalDiscount + getters.taxAmount + getters.shippingCost,
// Formatted string for display
formattedTotal: (ctx, getters) =>
`$${getters.total.toFixed(2)}`
},
on: {
addItem: (ctx, event) => ({
...ctx,
items: [...ctx.items, event.item]
}),
updateItem: (ctx, event) => ({
...ctx,
items: ctx.items.map(item =>
item.id === event.item.id ? { ...item, ...event.item } : item
)
}),
removeItem: (ctx, event) => ({
...ctx,
items: ctx.items.filter(item => item.id !== event.itemId)
})
}
});Without getters, any component that needs to display the subtotal, discounts, taxes, shipping, or total must recalculate them from the raw state. That leads to duplication and makes maintenance a nightmare if the business rules change.
|
|
In short, the PR’s core value is that it shifts the burden of derived state computation into the store, providing a “batteries‑included” solution. |
|
And I just want to add I would still use selectors for view specific logic, this is not a replacement. For domain logic I would use getters, for view specific rendering, or cross store derivations, I would use selectors. Imagine if you wanted to use the "ShoppingCart" entity on the backend (where there are no views) to validate incoming fields on a request is computed using the same business logic. I would want the logic to exist on the entity, not on the view. |
|
@davidkpiano Could you elaborate as to the parallels between these two pieces of code? |
|
I am dealing with the exact situation you described @expelledboy, they way I have implemented it now is using a derived atom like @davidkpiano is suggesting but I would prefer this getter API more 😄 |
Can you show me some sample code of how you're doing it now, vs. how you would like for it to be done (with this API)? |
So I haven't fleshed out my ideal API just yet but this is what i have for now. const store = createStore({
context: {
productsGroups: [
{
count: 1,
price: 10.0,
},
],
},
on: {
addToCart() {
// ...
},
incrementItem() {
// ...
},
},
});
const derivedCartValuesAtom = createAtom(() => {
const currentStore = store.get().context;
return {
totalPrice: currentStore.productsGroups.reduce(
(sum, item) => sum + /* item.price */ 0,
0
),
totalCount: currentStore.productsGroups.reduce(
(sum, item) => sum + /* item.count */ 0,
0
),
};
});On the react side I maintain 2 hooks const useCartSelector = (selector) => {
return useSelector(cartStore, selector)
}
const useDerivedCartSelector = (selector) => {
return useSelector(derivedCartValuesAtom, selector)
}While this is fine and works, it does decouple 2 kinds of state which i would prefer to keep closer together, if understand it correctly, with this API i would not need the second selector anymore, the getters would be available on the cart context when using a selector. Also maybe I just don't read the docs well enough but I did have to think for a moment how to handle this situation, make the tradeoff between =>
I feel like this getter API would remove some confusion on the best way to handle these situations, of course they're not always needed! Also a small caveat, which I don't think I have seen in the docs is that you must use Some food for thought, I hope i made some sense. |
|
I still think this is a good idea, but to bring this up-to-date with the current state of If merged, the code for your above example would be: const store = createStore({
context: {
productGroups: [{ count: 1, price: 10 }]
},
on: {
addToCart() { /* ... */ },
incrementItem() { /* ... */ }
},
selectors: {
totalPrice: (ctx) => ctx.productGroups.reduce((s, i) => s + i.price * i.count, 0),
totalCount: (ctx) => ctx.productGroups.reduce((s, i) => s + i.count, 0)
}
});
// React: one hook, one store
const totalPrice = useSelector(store.selectors.totalPrice);
// or just
store.selectors.totalPrice.get(); |
|
Hey @davidkpiano — I've had a look at #5490 and the selectors design is clean. I think you're right that it covers the reactive subscription use case better than what I had here. What I'd love to see preserved is snapshot embedding —
The cart example @NRodriguezcuellar shared illustrates this well: on the backend you'd want to call Concretely, I'm proposing that selectors defined in const store = createStore({
context: { count: 2 },
selectors: {
doubled: (ctx) => ctx.count * 2
},
on: { inc: (ctx) => ({ count: ctx.count + 1 }) }
});
store.selectors.doubled.get(); // reactive atom — #5490
store.getSnapshot().doubled; // snapshot-embedded — this PR's additionThe implementation overhead is minimal — values are computed once per transition and merged into the snapshot. Happy to update this PR to build on top of #5490's foundations if you think this direction is worth pursuing. |
|
@expelledboy I don't think it's a good idea to mix actual state and derived values in the snapshot directly. It blurs the line between "what the store holds" and "what can be computed from what the store holds". It also makes it trickier to persist snapshots (do we precompute all those derived values, which is expensive? Do we ignore them, which makes those props missing on restored snapshots) Looking at prior art:
None of the libraries put derived state on the snapshot. Is there a specific reason you want them directly on the snapshot? Is it just convenience? |
|
Took a closer look at #5490 — it covers two of the three things I need: co-location and memoization for simple selectors. But there's a gap worth flagging. The selector signature is My PR handles this with a second argument: selectors: {
subtotal: (ctx) => ctx.items.reduce((s, i) => s + i.price * i.quantity, 0),
tax: (ctx, s) => s.subtotal * ctx.taxRate,
total: (ctx, s) => s.subtotal + s.tax + ctx.shippingCost
}Each selector is still computed once and cached. The dependency chain is resolved lazily on first access. Not sure if this is in scope for #5490 or worth a separate issue — but wanted to flag it before closing this one. Happy to close if you'd rather track it elsewhere. |
Unfortunately it's not currently possible to do self-referential mapped type inference so that |
|
Yes you are correct, there was some manual typing necessary. But should a feature be limited when it can be implemented, but the type engine can't express it? In some domains recursive references are essential for optimal evaluation. |
#5184
Added store getters API for computed values derived from context: