artalar / reatom

Reatom - the ultimate state manager
https://reatom.dev
MIT License
989 stars 104 forks source link

Add "reaction" method #582

Closed artalar closed 6 months ago

artalar commented 1 year ago

About

So, after a few feedbacks (#578) we found a few problems with update hooks, which makes it not obvious and not useful.

After some investigations and have an inspiration from signals effect we found a nice - simple and powerful API which could solve a lot of cases and made it obvious. reaction - it is kind a signal effect, but with manual setup. It is look and work just like an atom, but the interface helps you to use it for a logic effects. Reaction accepts a callback with reactive spy and returns a special kind of action, which call activate the callback and returns a deactivate (unsubscribe) callback.

export interface Reaction<Params> extends Action<Params, Unsubscribe>

Example

export const searchAtom = atom("", "searchAtom");
export const suggestionsAtom = atom([], "suggestionsAtom");

// 1 option with limitations
// (OLD) need to declare types explicitly,
// the trigger is not obvious, but a running is automatic.
const fetchSuggestion = action(async (ctx, search: string) => {
  const response = await ctx.schedule(() =>
    fetch(`api/search?filter=${search}`)
  );
  const data = await response.json();
  suggestionsAtom(ctx, data);
}, "fetchSuggestion");
searchAtom.onChange(fetchSuggestion); // trigger
// you could handle promise for extra logic
fetchSuggestion.onCall((ctx, promise) => promise.catch(handleCatch));

// ---

// 2 option with limitations
// (OLD) the types for `search` inferred automatically - good,
// but the logic definition inside a hook is a bad pattern,
// coz it have worse logging and debugging experience.
// the trigger is obvious and a running is automatic.
searchAtom.onChange(async (ctx, search) => {
  const response = await ctx.schedule(() =>
    fetch(`api/search?filter=${search}`)
  );
  const data = await response.json();
  suggestionsAtom(ctx, data);
});
// you could NOT handle promise for extra logic

// ---

// 3 option with less limitations
// (NEW) no need to declare types explicitly,
// the trigger (`spy`) is obvious, but a running is manual!
export const reactSuggestion = reaction(async (ctx) => {
  const search = ctx.spy(searchAtom);
  const response = await ctx.schedule(() =>
    fetch(`api/search?filter=${search}`)
  );
  const data = await response.json();
  suggestionsAtom(ctx, data);
});
// you could handle promise for extra logic
reactSuggestion.onCall((ctx, promise) => promise.catch(handleCatch));

To run reactSuggestion you should call it as a regular action and it will return an unsubscribe function. Probably, you want to do it with onConnect.

The most important thing is that a combination of a few atom dependencies. For onChange codestyle you need extra combine operator with unobvious and dangerous behavior around update propagation. reaction handle it perfectly, as it uses common spy API and force you to create a subscription.

export const reactTableData = reaction(async (ctx) => {
  const filters = ctx.spy(filtersAtom);
  const sorting = ctx.spy(sortingAtom);
  const response = await ctx.schedule(() =>
    fetch(`api/search?filters=${filters}&sorting=${sorting}`)
  );
  const data = await response.json();
  tableDataAtom(ctx, data);
});

Implementation

The whole implementation is a super tiny, the bundle overhead is less than 100 gzip byte.

export interface Reaction<Payload = any> extends Action<[], Payload> {
  (ctx: Ctx): Unsubscribe;
}

export const reaction = <Payload>(
  cb: (ctx: CtxSpy) => Payload,
  name = __count("reaction")
): Reaction<Payload> => {
  const theReaction = Object.assign(
    (ctx: Ctx) => ctx.subscribe(theReaction, () => {}),
    action(name)
  ) as Reaction<Payload>;
  theReaction.__reatom.computer = (ctx, state = []) => [
    ...state,
    { params: [], payload: cb(ctx) },
  ];

  return theReaction;
};

Reuse the pattern in other packages

We expect to reuse the "reaction" pattern throughout the ecosystem.

const searchAtom = atom("", "searchAtom");

// old
const fetchIssues = reatomAsync(async (ctx, query: string) => {
  await sleep(350); // debounce
  const { items } = await api.fetchIssues(query, ctx.controller);
  return items;
}, "fetchIssues");
searchAtom.onChange(fetchIssues);

// new
const reactIssues = searchAtom.pipe(
  toAsyncReaction(async (ctx, query) => {
    await sleep(350);
    const { items } = await api.fetchIssues(query, ctx.controller);
    return items;
  }, "reactIssues")
);
export interface AsyncReaction<Param = any, Resp = any>
  extends AsyncAction<[Param], Resp> {
  (ctx: Ctx): Unsubscribe;
}
export const toAsyncReaction =
  <Param, Payload>(
    cb: (ctx: AsyncCtx, param: Param) => Promise<Payload>,
    name?: string
  ) =>
  (anAtom: Atom<Param>): AsyncReaction<Param, Payload> => {
    const theAsync = reatomAsync(cb, name);
    const theReaction = atom((ctx) => theAsync(ctx, ctx.spy(anAtom)));
    return Object.assign(
      (ctx: Ctx) => ctx.subscribe(theReaction, noop),
      theAsync
    );
  };
artalar commented 1 year ago

Another thoughts:


const filterAtom = atom("", "filterAtom");
const aResource = reatomResource((ctx) => {
  const filter = ctx.spy(filterAtom);
  return ctx.schedule(() => fetch(`/a?${filter}`).then((res) => res.json()));
});
const bResource = reatomResource((ctx) => {
  const filter = ctx.spy(filterAtom);
  return ctx.schedule(() => fetch(`/a?${filter}`).then((res) => res.json()));
});
const cResource = reatomResource(async (ctx) => {
  const a = await Promise.all([ctx.spy(aResource), ctx.spy(bResource)]);
  return a.map((data) => data.b);
});```