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 createQueryStore #6325

Open
wants to merge 27 commits into
base: develop
Choose a base branch
from
Open

Conversation

christianbaroni
Copy link
Member

@christianbaroni christianbaroni commented Dec 11, 2024

Fixes APP-2154

What changed (plus any additional context for devs)

  • Adds a new Zustand store creator, createQueryStore, which is a version of createRainbowStore with built-in fetching functionality, designed to be a more performant, more composable alternative to useQuery, for most use cases
  • Serves as a replacement for the current useQuery → Zustand sync pattern, which is inefficient and redundant
  • Adds automatic Map and Set persistence support to createRainbowStore
  • Adds a default partializer to createRainbowStore that automatically omits top-level store methods, which previously were being persisted when partialize was not specified

How It Works

  • The query store is internally aware of whether there are mounted components subscribed to its data via selectors — if there are, it handles refetching any time data becomes stale, and if there are not, the store remains or becomes dormant and stops refetching
  • Each query store maintains its own cache (optionally), which means serialization operations are local, limited in scope to each individual store's persisted state — there's no concept of a global cache as exists in RQ, which results in significantly lower overall serialization costs
  • Params are declared once per store, at the store level:
    params: {
      address: ($, store) => $(store).address, // Subscribe to internal query store state
      currency: $ => $(useSettingsStore).currency, // Subscribe to another store's state
      version: 2, // Static param
    }
    • As a result, components re-render only when the data they access changes (not when params change), and param definitions exist in one spot, logically centralizing how, when, and why data is fetched
    • Params can be either:
      • Live subscriptions to internal query store state
      • Live subscriptions to another Zustand store's state
      • Static
    • The dynamic params implementation repurposes ideas from zustand-signal. The $ function transforms a slice of a Zustand store into a live AttachValue. Under the hood, it’s a proxy that subscribes to changes in that slice of the store's state. When the specified state updates, the proxy immediately detects this, forcing the query store to recompute its internal queryKey based on the new params and refetch if needed.
    • See the API Examples below for more details on the dynamic params $ API
  • For a detailed overview of the API, see:

Notes

  • There's a bit of room for further type improvements
    • For instance set and get within the custom state creator currently are not aware of the query store's internal state
    • I may get around to these improvements before this PR is merged but I'd consider it non-blocking, as it doesn't materially affect or limit functionality in the vast majority of cases
  • Concurrent fetch requests are handled reasonably well currently, but it likely makes sense to add AbortController support, to in certain cases actually terminate already-triggered fetches (for instance in the event a param rapidly changes twice)
    • This wouldn't be fixing any real issues, it'd just be a slight efficiency gain, as currently internal data writes are tied to the query params used for a given fetch, which keeps things pretty robust
  • May want to add a helper setting for a manual mode, where no automatic refetching would occur (perhaps with the exception of the initial fetch), relying only on manually calling fetch for data updates
    • Currently you can achieve something close to this by specifying staleTime: Infinity (first-time fetches will still occur)

API Examples

The API In Its Simplest Form:

export const useBrowserDappsStore = createQueryStore<Dapp[]>(
  { fetcher: fetchDapps },
);

const MyComponent = () => {
  // Get data for the current params from the built-in query cache
  const top10Dapps = useBrowserDappsStore(state => state.getData()?.slice(0, 10));

  return <FeaturedDapps dapps={top10Dapps} />;
};

Dynamic $ Params:

Narrow state subscriptions linked directly to either internal or external store state.

export const useUserAssetsTestStore = createQueryStore<ParsedAssetsDictByChain, UserAssetsQueryParams, UserAssetsTestStore>(
  {
    fetcher: ({ address, currency }) => simpleUserAssetsQuery({ address, currency }),
    params: {
      address: ($, store) => $(store).address, // Subscribe to internal query store state
      currency: $ => $(useSettingsStore).userPrefs.currency, // Subscribe to another store's state
      version: 2, // Static param
    },
    staleTime: time.minutes(1),
  },

  // Optional custom store state
  set => ({
    address: testAddresses[0],
    setAddress: (address: Address) => set({ address }),
  }),

  // Optional RainbowPersistConfig (same API as createRainbowStore)
  { storageKey: 'queryStoreTest' }
);

No Params & Overriding Built-in Data Caching via setData:

export const useBrowserDappsStore = createQueryStore<Dapp[], never, DappsState>(
  {
    fetcher: fetchDapps,
    setData: ({ data, set }) => set({ dapps: data }),
    cacheTime: time.days(2),
    staleTime: time.minutes(20),
  },

  (_, get) => ({
    dapps: [],
    findDappByHostname: (hostname: string) => get().dapps.find(dapp => dapp.urlDisplay === hostname),
  }),

  { storageKey: 'browserDapps' }
);

Screen recordings / screenshots

Two copies of the same query store implementation, each with their own state and a unique address param, both leveraging persistence and dynamic $ params (you wouldn't want two copies of the same store in practice, that's purely for testing purposes):

ScreenRecording_12-23-2024.18-02-12_1.MP4

What to test

  • There's an example query store and component here, which can be rendered anywhere in the app for testing: QueryStoreTest.tsx
    • Planning to comment out this file before merging to avoid any effect on prod — I think it's useful to keep around for testing and further store improvements

Copy link

linear bot commented Dec 11, 2024

@christianbaroni christianbaroni changed the title Add createRemoteRainbowStore Add createRainbowQueryStore Dec 16, 2024
@christianbaroni christianbaroni changed the title Add createRainbowQueryStore Add createQueryStore Dec 17, 2024
Comment on lines +392 to +395
if (activeRefetchTimeout) {
clearTimeout(activeRefetchTimeout);
activeRefetchTimeout = null;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if i'm understanding correctly, you can only have one active refetch at a time. alternatively, could map queryKeys to timeoutIds and hold one for each query you want to poll.

Copy link

@tsehon tsehon Dec 21, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

after taking a glance at your example, it seems stores might only get set up for a mutually exclusive queries?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now it’s a query-per-store model — each query store operates independently, though a store's query params can be defined as dynamic subscriptions to any store’s state using the $ API.

It would be pretty straightforward to support multiple queries, since the caching system is already set up to handle multiple keys — just wanted to keep it simple initially and get all the core functionality working. I do think the single-query model may still be favorable since it encourages keeping stores small and modular, which helps limit serialization overhead.

* ```ts
* const isInitialLoad = useQueryStore(state => state.getStatus().isInitialLoad);
* ```
* @returns An object containing boolean flags for each status.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

curious why this is useful

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isInitialLoad in particular or the whole status function?

Not sure I’ll keep either exactly as is currently. I think it’s all a bit redundant given status and error are exposed, and isInitialLoad in most cases is essentially just the absence of data. It’s mainly for consistency with react-query — personally I’d rarely use these helpers.

On isInitialLoad, you might want to for instance show a loading skeleton the first time the data loads (when there is no cached data), but not during refetches when cached data exists and can be displayed instead of a loading skeleton while new data is fetched.

@christianbaroni christianbaroni marked this pull request as ready for review December 23, 2024 22:09
@christianbaroni christianbaroni added the performance performance related improvements label Dec 23, 2024
@brunobar79
Copy link
Member

Launch in simulator or device for 51dcc1c

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

Successfully merging this pull request may close these issues.

3 participants