effector / patronum

☄️ Effector operators library delivering modularity and convenience ✨
https://patronum.effector.dev
MIT License
297 stars 43 forks source link

Operator proposal: `defer` #329

Open velialiev opened 6 months ago

velialiev commented 6 months ago

Motivation

Web is asynchronous by it's nature and there are a lot of cases when we wait for something, defer/postpone something and so on.

Let's assume we have a user that want to pay using his wallet created in our app. He asks us how to do it and we want to give him an url that either will open wallet creation or wallet details depending on whether he already created the wallet or no.

Both of wallet creation and wallet details are dialogs belonging to the pay page. Existence of the wallet is checked by the request inited by pay page and buttons that open this dialogs are disabled until data is loaded. Everything worked pretty well until we decided to give user a direct url that opens one of these dialogs. The problem is that the data needed to determine what dialog to open is still loading.

Sure we can just rework our UI to have one dialog that shows loading until data is loaded and renders different contents despite these dialogs are not related at all but the essence of the problem is that we have a synchronous event of opening our url and want to handle it asynchronously. In imperative API like effect handler we can do it with ease using async/await but there is no solution for declarative API

Solution

Since almost everything is asynchronous in web, sometimes we want to handle events asynchronously. In imperative API like effect handler we can do it with ease using async/await, but there is no solution for declarative approach. We can rely on effect's done/fail events to call event after an asynchronous action but this works only in case when we have event -> effect -> another event chain. There is a case when event doesn't trigger effect but depends on data fetched by it. Effect in the other hand is called by another trigger.

In this case we have two branches of code:

  1. If data ALREADY fetched then we should handle an event
  2. If data IS NOT fetched YET then we should wait for data and then handle an event

That is where defer operator comes in.

sample({
  clock: defer({ clock: event, until: $itCanBeHandled }),
  target: handleEventFx,
})

You can read this as "when event is happened check if $itCanBeHandled is true. If so call the handleEventFx. If no wait until $itCanBeHandled became true and then call handleEventFx.

Let's try to solve the problem described in motivation. We want to open either wallet creation or wallet details dialog depending on whether wallet exists by direct url

split({
  clock: defer({ clock: walletUrlOpened, until: equals(walletQuery.status, 'done') }),
  source: $wallet,
  match: (wallet) => (wallet ? 'hasWallet' : 'noWallet'),
  cases: {
    hasWallet: walletDialog.open,
    noWallet: walletCreationDialog.open,
  },
})

In this case we will run the split only when $wallet be ready to determine what dialog should be opened.

Details

If clock will be called more than 1 time only the last event will be deferred and called. Other events will be ignored

velialiev commented 6 months ago

I've added implementation draft: https://github.com/effector/patronum/pull/330

If proposal would be approved I'll add tests and docs there

victordidenko commented 6 months ago

In our codebase we use this simple operator:

export function postpone<T>({
  clock,
  until,
  target,
}: {
  clock: Event<T>
  until: Store<boolean>
  target: Event<T>
}): Event<T> {
  return sample({
    clock: [clock, until],
    source: clock,
    filter: until,
    target,
  })
}

Looks like you are proposing the same?

velialiev commented 6 months ago

In our codebase we use this simple operator:

export function postpone<T>({
  clock,
  until,
  target,
}: {
  clock: Event<T>
  until: Store<boolean>
  target: Event<T>
}): Event<T> {
  return sample({
    clock: [clock, until],
    source: clock,
    filter: until,
    target,
  })
}

Looks like you are proposing the same?

If i got it right your operator calls the target when until's value changes from false to true even if clock is not called yet. The operator I propose will call target ONLY when clock is called but only when until became true (it will call it immediately if until was already be true when clock was called)

You can check the details in implementation draft PR

victordidenko commented 6 months ago

even if clock is not called yet

No, sample will not trigger target if event in source was not triggered

image

But this operator will not reset its state, as your variant

velialiev commented 6 months ago

even if clock is not called yet

No, sample will not trigger target if event in source was not triggered

image

But this operator will not reset its state, as your variant

Wow! Great catch :) It's first time I see someone using this limitation as the feature. Actually I saw using event as the source only twice including your case

But yeah, as you've mentioned above your solution will not reset it's state and will be triggered when until changes to true right after clock will be called at least once

But our solutions is very similar and solve same problem. Thanks for sharing!

illright commented 5 months ago

This operator would be useful to me. Here's the use case I have:

I have a store that often receives updates, every new update basically invalidates the previous states. I want to process these updates using an asynchronous effect, but to avoid calling the processing effect too many times, I would like to defer processing a new update until the processing of the old update is finished. It's almost the same logic as filter in sample, except I need the update not to get lost, but rather the processing of it to be deferred.

This cannot be achieved using the method proposed above because my store is not a one-off, so the event will be called repeatedly.

illright commented 5 months ago

Copy-pasted the operator from the PR, works great. Here's the solution that worked for me:

const treeChanged = debounce(vfs.$tree, 500)
const runRulesFx = createEffect(runRules)

sample({
  clock: defer({ clock: treeChanged, until: not(runRulesFx.pending) }),
  source: vfs.$tree
  target: runRulesFx,
})

The source here is optional, but I found that, semantically, the name defer doesn't indicate what its value is, it's only about when it emits, that's why I opted to explicitly add the source