Skip to content

Add getter api to @xstate/store in main#5191

Open
expelledboy wants to merge 5 commits intostatelyai:mainfrom
expelledboy:feat/store-getters
Open

Add getter api to @xstate/store in main#5191
expelledboy wants to merge 5 commits intostatelyai:mainfrom
expelledboy:feat/store-getters

Conversation

@expelledboy
Copy link
Copy Markdown

@expelledboy expelledboy commented Feb 9, 2025

#5184

Added store getters API for computed values derived from context:

const store = createStore({
  context: { count: 2 },
  getters: {
    doubled: (ctx) => ctx.count * 2,
    squared: (ctx) => ctx.count ** 2,
    // Can depend on other getters (types can not be inferred, due to circular references)
    sum: (ctx, getters: { doubled: number; squared: number }) =>
      getters.doubled + getters.squared
  },
  on: {
    inc: (ctx) => ({ count: ctx.count + 1 })
  }
});

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Feb 9, 2025

🦋 Changeset detected

Latest commit: 02fa5ce

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@xstate/store Minor

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

@expelledboy
Copy link
Copy Markdown
Author

@davidkpiano I managed to resolve all the issue with the PR. Please review and let me know what you think.

@davidkpiano
Copy link
Copy Markdown
Member

Also, sorry for the delay, but can you explain the benefits of getters, over more explicit selectors?

@expelledboy
Copy link
Copy Markdown
Author

By incorporating getters directly into your store, you centralize the derivation of computed data, ensuring consistency across all components while eliminating redundant logic. This not only makes your application easier to maintain and update but also leverages built-in memoization for enhanced performance. In essence, getters encapsulate essential "domain" logic, reduce duplication, and provide a cleaner, more intuitive API, resulting in a more robust and developer-friendly state management solution.

Imagine our cart store has the following raw state:

  • An array of items, where each item has a price, quantity, discount percentage, and tax rate.
  • Global parameters such as a shipping threshold and base shipping cost.
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.

  1. Centralization & Consistency:
    All computations—subtotal, discounts, tax, shipping, total, and formatting—are defined once within the store. Every consumer accesses the same derived values from cartStore.getters.

  2. Reduced Duplication & Easier Maintenance:
    Without getters, each component would need to recalculate these values from raw state. With getters, if business rules change (say, adding a volume discount or modifying tax logic), you only need to update the store’s computed functions rather than hunting down every component that performs the calculation.

  3. Performance through Memoization:
    If the items haven’t changed, the derived values aren’t recalculated on every access—even if multiple components display the total. This minimizes unnecessary computations during re‑renders.

  4. Encapsulation of Domain Logic:
    The logic for computing totals is intrinsic to the cart’s domain. Embedding this logic in the store means that components only need to know about “total” rather than how it’s derived. The store becomes a self‑contained model of the cart, with all business rules in one place.

  5. Improved Developer Experience:
    Consumers simply read from the store API (e.g. cartStore.getters.total or cartStore.getters.formattedTotal) and don’t need to worry about the implementation details. This makes the code cleaner, easier to test, and reduces cognitive load when updating or debugging the cart’s logic.

@expelledboy
Copy link
Copy Markdown
Author

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.

@expelledboy
Copy link
Copy Markdown
Author

expelledboy commented Feb 22, 2025

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
Copy link
Copy Markdown
Member

Still considering this, especially how it plays with the atom PR: #5221

cc. @Andarist for your thoughts

@expelledboy
Copy link
Copy Markdown
Author

@davidkpiano Could you elaborate as to the parallels between these two pieces of code?

@NRodriguezcuellar
Copy link
Copy Markdown

NRodriguezcuellar commented Jun 19, 2025

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 😄

@davidkpiano
Copy link
Copy Markdown
Member

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)?

@NRodriguezcuellar
Copy link
Copy Markdown

NRodriguezcuellar commented Jun 19, 2025

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.
For my case I am using react, and i have store which holds all the products in my cart, which looks something like:

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 =>

  • Keeping it in the context and manually making sure that it gets updated in each event by wrapping all return with recomputeDerivedValues(context) (risks getting out of date)
  • recompute on render every time in the hook, but now my state is only in the hook :(

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 .get() instead of .getSnapshot() while the jsdoc description describes it as @alias getSnapshot, while under the hood there is a real difference! Because .getSnapshot() will not make the atom react to store changes.

Some food for thought, I hope i made some sense.

@davidkpiano
Copy link
Copy Markdown
Member

I still think this is a good idea, but to bring this up-to-date with the current state of @xstate/store, this will likely be superseded by #5490

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();

@expelledboy
Copy link
Copy Markdown
Author

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 embeddingstore.getSnapshot() returning computed values alongside context. The two aren't in conflict:

  • store.selectors.doubled.get() → reactive atom, great for React, fine-grained subscriptions, composable with createAtom
  • store.getSnapshot().doubled → snapshot-embedded, great for server-side code, tests, serialization, logging, any consumer that just calls getSnapshot() once

The cart example @NRodriguezcuellar shared illustrates this well: on the backend you'd want to call getSnapshot() and get a complete picture of the entity — including derived totals — without needing to wire up atom subscriptions. store.selectors.totalPrice.get() works, but it puts the burden on every consumer to know which derived values exist and call .get() on each one.

Concretely, I'm proposing that selectors defined in createStore do both:

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 addition

The 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.

@davidkpiano
Copy link
Copy Markdown
Member

@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:

  • Zustand keeps selectors external
  • Pinia has getters as a first-class concept but those live on the store, not the state
  • RTK has selectors as standalone functions
  • MobX uses computed on the store, not on any snapshot, and toJS() excludes those computed properties

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?

@expelledboy
Copy link
Copy Markdown
Author

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 (ctx) => value — no access to sibling selectors. That becomes a problem when you have layered domain logic, like the cart example I gave earlier. To compute total you need subtotal and tax, and to compute tax you need subtotal. Without inter-selector dependencies you either duplicate the computation inside each selector (which defeats memoization — subtotal runs three times on a single total read) or you reach for createAtom outside the store config, which breaks co-location.

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.

@davidkpiano
Copy link
Copy Markdown
Member

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.

Unfortunately it's not currently possible to do self-referential mapped type inference so that s inside would be any which isn't that great (cc. @Andarist)

@expelledboy
Copy link
Copy Markdown
Author

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.

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 this pull request may close these issues.

3 participants