nanostores / query

⚡️ Powerful data fetching library for Nano Stores. TS/JS. Framework agnostic.
MIT License
228 stars 10 forks source link

`createFetcherStore` Making Request With Old Query Keys With Dynamic Params #32

Closed martinmckenna closed 8 months ago

martinmckenna commented 9 months ago

Reproducible Example: https://codesandbox.io/p/sandbox/query-test-n8nq75?file=%2Fsrc%2FTest.tsx%3A10%2C3

Hi! I've implemented some wrapping code after looking through the suggestion from this issue: https://github.com/nanostores/query/issues/16

Basically I have a wrapping hook like this (based on the README file in this repo about sending dynamic arguments):

import { useStore } from '@nanostores/react';
import { Store } from 'nanostores';
import { useMemo } from 'react';

export const useFetcher = <F extends Store>(fetcher: F, deps?: any[]) => {
  /*
    useMemo with deps instead of useState because I need `createFetcherStore`
    to react to arguments changing
  */
  const $store = useMemo(() => fetcher, deps || []);
  return useStore($store);
};

and my store:

  export const $getThings = (
    type: string,
  ) =>
    createFetcherStore(['my-things', type], {
      fetcher: () =>
        getThingsByType(type).then((response) => response.data),
    });

and my react component:

import { useFetcher } from "hooks/useFetcher";

const MyComponent = () => {
  const [type, setType] = useState("default-type");

  const { data, loading } = useFetcher($getThings(type), [type]);

  return <select onChange={setType} />;
};

Now, this all works perfectly. When I change the value in my select dropdown, it successfully makes the request with the updated data.

The unintended side-effect however, is that after 4 seconds (the time which the data is requested fresh), any change to my dropdown will trigger 2 requests: 1 with the old type and 1 with the new type

Video here. You can see 1 request goes off normally, but after 4 seconds, it will make 2 requests:

https://imgur.com/YZn4RE1

My question here is if there's any way to prevent the request from being fired with the old query key

martinmckenna commented 9 months ago

also wanted to mention that much like the person in https://github.com/nanostores/query/issues/16, converting all my state to nanostores atoms isn't really an option, so I need a way to be able to send these arguments down. I feel like this is working exactly as I want, besides for this quirk.

Also wanted to point out I've tried this code:

import { useStore } from '@nanostores/react';
import { Store } from 'nanostores';
import { useState } from 'react';

export const useFetcher = <F extends Store>(fetcher: F) => {
  const [$store] = useState(() => fetcher);
  return useStore($store);
};

but as you can see in the video, no request is made when my arguments change because the store is being set once and never again

https://imgur.com/zI9YZ7K

martinmckenna commented 8 months ago

Closing because I think I found an abstracted way to pass arguments to my store, without creating a new store everytime.

Taking inspiration from this issue: https://github.com/nanostores/query/issues/16, I made a react hook called useFetcher that will take a function that takes arguments and returns a createFetcherStore and will convert the passed dependencies into atoms, then expose methods for passing those atoms to your created store:

import { useStore } from '@nanostores/react';
import { atom, Store, WritableAtom } from 'nanostores';
import { useRef, useState } from 'react';

export function useFetcher<
  F extends Store | ((...args: any[]) => Store),
  K extends string,
  V extends any,
  R extends Record<K, V>,
>(fetcher: F, deps?: R) {
  /*
    take our dependencies (props, react state, or just normal variables) 
    and turn them into nanostores atoms

    wrap in a useRef, so the atoms remain unchanged
  */
  const depsAsAtoms = useRef(
    Object.keys(deps || {}).reduce(
      (acc, eachKey) => {
        if (deps) {
          acc[eachKey] = atom(deps[eachKey]);
        }
        return acc;
      },
      {} as Record<keyof R, WritableAtom<R[keyof R]>>,
    ),
  );

  /*
    return a method for updating the converted atoms, so that the fetcher
    store will react to changes
  */
  const updateAtom = <Key extends keyof R>(key: Key, newValue: R[Key]) => {
    depsAsAtoms.current[key].set(newValue);
  };

  /*
    if we passed dependencies, return to the consumer the store, a getter
    and setter so they can call `useStore` with the arguments
  */
  return {
        useGenerateStore: (...args: any[]) =>
          useState(
            typeof fetcher === 'function' ? fetcher(...args) : fetcher,
          )[0],
        getAtom: <Key extends keyof R>(key: Key) => depsAsAtoms.current[key],
        updateAtom,
      }
}

and the usage looks like:

export const $getThings = (page: WritableAtom<number>) = createFetcherStore(
  ['things', page],
  {
    fetcher: () => fetch(`/things?page=${page}`),
  },
);

const {
  useGenerateStore,
  getAtom,
  updateAtom
} = useFetcher($getThings, {
  /* could be a prop, react state, normal variable, whatever */
  page: 1
})

const { data, loading, error } = useStore(useGenerateStore(getAtom("page")))

<button onClick={() => updateAtom("page", 2)} />

All the extra typescript will ensure the arguments you're passing to getAtom, updateAtom and useGenerateStore are all what they need to be

dkzlv commented 8 months ago

@martinmckenna Here's something that may be interesting to you.

Generally, the idea behind nanostores is to use reactive model of a framework as little as possible. This way your code is very portable between frameworks and uses the same very straightforward reactivity model everywhere. The only significant problem is the local state, the one that have uniqueness of a single component instance and not JS module (singletons).

This is purely react-specific, or concurrent-mode specific even. See there are 2 major problems:

  1. you can't memoize things—React can and eventually will drop memoization and call your hooks more often than you expect. If you try to memoize anything, at some point you'll lose state, because it will recreate your stores.
  2. you actually want to create more than a single store, right? You want to use lifecycle hooks, for example, or create derivative states. It's not a huge win if you still need to use useEffect for any effect, imo.

So this is what I came up with. It's a bit verbose but overall is quite powerful in my opinion.

import { useStore } from "@nanostores/react";
import { Emitter, createNanoEvents } from "nanoevents";
import { Store, atom, onMount } from "nanostores";

type Fn = () => void;

const initializedMap = new Map<string, unknown>();
const signalStoresMap = new Map<string, Store>();
const emittersMap = new Map<string, Emitter<{ unbind: Fn }>>();

const makeSignalStore = (cleanup: Fn) => {
  const $store = atom();
  onMount($store, () => cleanup);
  return $store;
};

export type OnUnbind = (cb: Fn | Fn[]) => void;

/**
 * Getting around React's idea to call stuff multiple times, plus making it possible to have
 * local state stored in nanostores.
 *
 * Pass in a persistent key and a callback.
 * We guarantee it'll be called once and _only_ once. There's also automatic cleanup.
 */
export function useFactory<T>(
  key: string,
  factory: (onUnbind: OnUnbind) => T
): T {
  let emitter = emittersMap.get(key);
  if (!emitter) {
    emitter = createNanoEvents();
    emittersMap.set(key, emitter);
  }

  let $signalStore = signalStoresMap.get(key);
  if (!$signalStore) {
    $signalStore = makeSignalStore(() => {
      initializedMap.delete(key);
      signalStoresMap.delete(key);
      emittersMap.delete(key);
      exists(emitter).emit("unbind");
    });
    signalStoresMap.set(key, $signalStore);
  }
  truthy(emitter);
  truthy($signalStore);

  useStore($signalStore);

  const cached = initializedMap.get(key);
  if (cached) return cached as T;

  const res = factory((unbindCb) => {
    const innerUnbind = exists(emitter).on("unbind", () => {
      if (Array.isArray(unbindCb)) unbindCb.forEach((fn) => fn());
      else unbindCb();

      innerUnbind();
    });
  });
  initializedMap.set(key, res);
  return res;
}

function truthy<T>(val: T | false | null | undefined): asserts val is T {
  if (val === void 0 || val === false || val === null) {
    throw new Error("Cannot be falsy");
  }
}

function exists<T>(val: T | false | null | undefined): T {
  truthy(val);
  return val;
}

Now, it gives a cool thing: it allows you to use the power of nanostores for local state—alas you need to create a function scope for it, but that's not really an issue. This is an example of a component that uses it. The makeProgressState is only called once for the same key, ever, it's not prone to concurrent mode whatsoever, so it never loses state. Inside I define AN INTERVAL, without any fancy things. It's guaranteed not to have any leaks, and it simplifies the local state management significantly since this function body is only executed once (as if it was Solid, hehe).

const makeProgressState = (
  { slowDownAtPercent, timeToFinishInSeconds }: StateProps,
  onUnbind: OnUnbind
) => {
  const $value = atom(0);

  // Calculate the initial step size
  const totalTicks = (timeToFinishInSeconds * 1000) / 500;
  const initialStep = 100 / totalTicks;

  // When to slow down
  const slowDownAtValue = 100 * slowDownAtPercent;
  let step = initialStep;

  const int = setInterval(() => {
    let current = $value.value as number;

    // Check if it's time to slow down
    if (current >= slowDownAtValue) {
      step = initialStep / 10;
    }

    // Update the progress and set it
    current += step;
    if (current > 100) current = 100;
    $value.set(current);

    // If 100% progress reached, clear the interval
    if (current >= 100) {
      clearInterval(int);
    }
  }, 500);

  onUnbind(() => clearInterval(int));

  return $value;
};

type StateProps = {
  progressKey: string;
  slowDownAtPercent: number;
  timeToFinishInSeconds: number;
};

type Props = ComponentProps<typeof Progress> & { conf: StateProps };

export const GradualProgress = ({ conf, ...rest }: Props) => {
  const $value = useFactory(`gradualProgress_${conf.progressKey}`, (unbind) =>
    makeProgressState(conf, unbind)
  );

  return <Progress {...rest} value={useStore($value)} />;
};

Anyways, not endorsing you to use it, that's purely my own experiments. Maybe I'll publish it as a library one day.