Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 41 additions & 9 deletions src/throttle/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,14 @@ export function throttle<T>(_: {
source: Unit<T>;
timeout: number | Store<number>;
name?: string;
leading?: boolean;
}): EventAsReturnType<T>;
export function throttle<T, Target extends UnitTargetable<T>>(_: {
source: Unit<T>;
timeout: number | Store<number>;
target: Target;
name?: string;
leading?: boolean;
}): Target;
export function throttle<T>(
...args:
Expand All @@ -35,16 +37,18 @@ export function throttle<T>(
timeout: number | Store<number>;
name?: string;
target?: UnitTargetable<any>;
leading?: boolean;
},
]
| [Unit<T>, number | Store<number>]
): EventAsReturnType<T> {
const argsShape =
args.length === 2 ? { source: args[0], timeout: args[1] } : args[0];
const { source, timeout, target = createEvent<T>() } = argsShape;
const { source, timeout, leading, target = createEvent<T>() } = argsShape;
if (!is.unit(source)) throw new TypeError('source must be unit from effector');

const $timeout = toStoreNumber(timeout);
const $leading = toStoreBoolean(leading ?? false);

const timerFx = createEffect<number, void>({
name: `throttle(${(source as Event<T>).shortName || source.kind}) effect`,
Expand All @@ -57,28 +61,44 @@ export function throttle<T>(
skipVoid: false,
}).on(source, (_, payload) => payload);

const triggerTick = createEvent<T>();
const trailingTick = createEvent<void>();
const leadingTick = createEvent<void>();

const $canTick = createStore(true, { serialize: 'ignore' })
.on(triggerTick, () => false)
.on(target, () => true);
const $canTrailingTick = createStore(true, { serialize: 'ignore' })
.on(trailingTick, () => false)
.on(timerFx.done, () => true);

const $canLeadingTick = createStore(true, { serialize: 'ignore' })
.on(leadingTick, () => false)
.on(timerFx.done, () => true);

sample({
clock: source,
source: [$canTrailingTick, $leading, $canLeadingTick],
filter: ([canTrailingTick, leading, canLeadingTick]) =>
canTrailingTick && ((!canLeadingTick && leading) || !leading),
fn: (_, clock) => clock,
target: trailingTick,
});

sample({
clock: source,
filter: $canTick,
target: triggerTick,
source: [$canTrailingTick, $canLeadingTick],
filter: ([canTrailingTick, canLeadingTick]) => canTrailingTick && canLeadingTick,
fn: (_, clock) => clock,
target: [target, leadingTick],
});

sample({
source: $timeout,
clock: triggerTick as Unit<any>,
clock: trailingTick as Unit<any>,
target: timerFx,
});

sample({
source: $payload,
clock: timerFx.done,
target,
target: target,
});

return target as any;
Expand All @@ -98,3 +118,15 @@ function toStoreNumber(value: number | Store<number> | unknown): Store<number> {
`timeout parameter should be number or Store. "${typeof value}" was passed`,
);
}

function toStoreBoolean(value: boolean | Store<boolean> | unknown): Store<boolean> {
if (is.store(value)) return value;

if (typeof value !== 'boolean') {
throw new TypeError(
`leading parameter should be boolean or Store. "${typeof value}" was passed`,
);
}

return createStore(value, { name: '$leading' });
}
92 changes: 92 additions & 0 deletions src/throttle/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,98 @@ const throttledStore: Event<number> = throttle({
});
```

## `throttle({ source, timeout, leading })`

### Motivation

By default, `throttle` triggers `target` at the **end** of the timeout (trailing edge). With `leading: true`, the first call triggers `target` **immediately**, and subsequent calls within the timeout period are throttled normally.

This is useful when you want immediate feedback on the first interaction, while still preventing excessive calls.

### Formulae

```ts
target = throttle({ source, timeout, leading: true });
```

- First trigger of `source` immediately triggers `target`
- Subsequent triggers within `timeout` are collected, and only the last value triggers `target` after `timeout`

### Arguments

1. `source` ([_`Event`_] | [_`Store`_] | [_`Effect`_]) — Source unit, data from this unit used by the `target`
2. `timeout` ([_`number`_] | `Store<number>`) — time to wait before trigger `target` after last trigger
3. `leading` (`boolean`) — if `true`, first call triggers immediately. Default: `false`
4. `target` ([_`Event`_] | [_`Store`_] | [_`Effect`_]) — (optional) Target unit

### Returns

- `target` ([_`Event`_]) — new event, that triggered on first call immediately (if `leading: true`) and after timeout with the last value

### Usage

```ts
import { createEvent } from 'effector';
import { throttle } from 'patronum';

const buttonClicked = createEvent<number>();

const throttled = throttle({
source: buttonClicked,
timeout: 200,
leading: true,
});

throttled.watch((payload) => {
console.info('clicked', payload);
});

buttonClicked(1); // => clicked 1 (immediately)
buttonClicked(2); // (ignored, within timeout)
buttonClicked(3); // (ignored, within timeout)
buttonClicked(4); // (saved as last value)

// after 200ms
// => clicked 4
```

### Comparison: `leading: false` vs `leading: true`

Given `timeout: 200ms`:

#### `leading: false` (default behavior)

| Time | Action | State | target fires? |
|------|--------|-------|---------------|
| 0ms | `source(1)` | Timer starts, saved=1 | ❌ No |
| 50ms | `source(2)` | saved=2 | ❌ No |
| 100ms | `source(3)` | saved=3 | ❌ No |
| 200ms | Timer done | — | ✅ **target(3)** |
| 200ms | `source(4)` | Timer starts, saved=4 | ❌ No |
| 250ms | `source(5)` | saved=5 | ❌ No |
| 400ms | Timer done | — | ✅ **target(5)** |

**Result:** target fires **2 times** with values `3` and `5` (only trailing edge).

#### `leading: true`

| Time | Action | State | target fires? |
|------|--------|-------|---------------|
| 0ms | `source(1)` | Timer starts, saved=1 | ✅ **target(1)** (immediate!) |
| 50ms | `source(2)` | saved=2 | ❌ No |
| 100ms | `source(3)` | saved=3 | ❌ No |
| 200ms | Timer done | — | ✅ **target(3)** |
| 200ms | `source(4)` | Timer starts, saved=4 | ✅ **target(4)** (immediate!) |
| 250ms | `source(5)` | saved=5 | ❌ No |
| 400ms | Timer done | — | ✅ **target(5)** |

**Result:** target fires **4 times** with values `1`, `3`, `4`, `5` (leading + trailing edges).

#### Key difference

- **`leading: false`**: User must wait for timeout before seeing any result
- **`leading: true`**: User gets immediate feedback on first interaction, then throttled updates

[_`event`_]: https://effector.dev/docs/api/effector/event
[_`effect`_]: https://effector.dev/docs/api/effector/effect
[_`store`_]: https://effector.dev/docs/api/effector/store
Expand Down
64 changes: 64 additions & 0 deletions src/throttle/throttle.fork.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,3 +157,67 @@ describe('edge cases', () => {
expect(listener).toBeCalledWith('two');
});
});

describe('leading option', () => {
test('leading throttle works in forked scope', async () => {
const app = createDomain();
const source = app.createEvent<number>();
const $counter = app.createStore(0);

const throttled = throttle({ source, timeout: 40, leading: true });

$counter.on(throttled, (value, payload) => value + payload);

const scope = fork();

await allSettled(source, { scope, params: 1 });

expect(serialize(scope)).toMatchObject({
[$counter.sid!]: 1,
});
});

test('leading throttle do not affect another forks', async () => {
const app = createDomain();
const $counter = app.createStore(0);
const trigger = app.createEvent<number>();

const throttled = throttle({ source: trigger, timeout: 40, leading: true });

$counter.on(throttled, (value, payload) => value + payload);

const scopeA = fork();
const scopeB = fork();

await allSettled(trigger, { scope: scopeA, params: 1 });
await allSettled(trigger, { scope: scopeB, params: 100 });

expect(serialize(scopeA)).toMatchObject({
[$counter.sid!]: 1,
});

expect(serialize(scopeB)).toMatchObject({
[$counter.sid!]: 100,
});
});

test('leading throttle do not affect original store value', async () => {
const app = createDomain();
const $counter = app.createStore(0);
const trigger = app.createEvent<number>();

const throttled = throttle({ source: trigger, timeout: 40, leading: true });

$counter.on(throttled, (value, payload) => value + payload);

const scope = fork();

await allSettled(trigger, { scope, params: 5 });

expect(serialize(scope)).toMatchObject({
[$counter.sid!]: 5,
});

expect($counter.getState()).toBe(0);
});
});
Loading