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

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

Open
wants to merge 5 commits into
base: main
Choose a base branch
from

Conversation

expelledboy
Copy link

@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 })
  }
});

Copy link

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
Author

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

@davidkpiano
Copy link
Member

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

@expelledboy
Copy link
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
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
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.

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.

2 participants