Wait for condition
Kiss comes with a few functions to help you wait until some condition is met. You can create conditions that wait until the store's state meets a condition, or until certain actions are no longer in progress.
- waitCondition waits until the state is in a condition.
- waitAllActions waits until actions are not in progress.
- waitAllActionTypes waits until all action types are NOT in progress.
- waitActionType waits until an action of a type is not in progress.
- waitAnyActionTypeFinishes waits until action types finish dispatching.
- waitActionCondition waits until actions in progress meet a condition.
waitCondition
You can use the waitCondition function to wait until the app state meets a condition.
In more detail, you get a promise which will resolve when a given state condition is true.
For example, suppose your state contains a stocks object that allows you to get the
stock price for a given stock:
let price = state.stocks.getPrice('IBM');
Now suppose you want to wait until the current price of IBM is 100 or more. This is how you can do it:
await waitCondition(
(state) => state.stocks.getPrice('IBM') >= 100,
{ timeoutMillis: 0 }, // No timeout.
);
In actions, you can use this.waitCondition. For example:
class SellStockForPrice extends Action {
constructor(public stock: string, public price: number) { super(); }
async reduce() {
// Wait until the stock price is higher than the limit price
await this.waitCondition(
(state) => state.stocks.getPrice(this.stock) >= this.price,
{ timeoutMillis: 0 }, // No timeout.
);
// Only then post the sell order to the backend
let amount = await postSellOrder(this.stock);
return (state: State) =>
state.copy({
stocks: state.stocks.setAmount(this.stock, amount)
});
}
}
While it waits, the condition runs on every state change. A few short-lived waits are fine. But many waits, or waits that may never complete (especially with no timeout), add a cost to every state change for as long as the store lives, since there is no way to cancel a wait.
In production code, it's often better to put the logic in an action, or to react to the selected state in your components.
If the condition is already true when the waitCondition function is called,
the promise resolves immediately.
Timeout
You must always say how long waitCondition should wait, with the timeoutMillis parameter.
There is no default, because the right timeout depends on what you are waiting for.
In the example above, the price may take days to reach 100, so it makes sense to wait with no timeout.
But in a test, you'll want a short timeout, so that the test fails instead of hanging.
For example, this times out after 1 second (1000 milliseconds):
let action = await store.waitCondition(
(state) => state.name == "Bill",
{ timeoutMillis: 1000 },
);
If the condition is not met in time, the promise rejects with a TimeoutException,
and the condition stops being checked. You can catch it:
try {
await store.waitCondition((state) => state.name == "Bill", { timeoutMillis: 1000 });
} catch (error) {
if (error instanceof TimeoutException) { /* Timed out. */ }
else throw error;
}
Or, you can pass an onTimeout callback.
If the timeout expires, onTimeout is called,
and the promise resolves with null, instead of rejecting:
let action = await store.waitCondition(
(state) => state.name == "Bill",
{ timeoutMillis: 1000, onTimeout: () => console.log('Timed out.') },
);
If onTimeout throws an error, the promise rejects with that error.
To wait with no timeout, pass { timeoutMillis: 0 } (or -1):
await store.waitCondition((state) => state.name == "Bill", { timeoutMillis: 0 });
dispatchWhen
The special dispatch function dispatchWhen allows you to
wait until the store's state meets a certain condition, and then dispatch an action.
For example, this will dispatch a BuyStock action when the price of IBM is 100 or more:
dispatchWhen(
new BuyStock('IBM'),
(state) => state.stocks.getPrice('IBM') >= 100,
{ timeoutMillis: 0 }, // No timeout.
);
Like waitCondition, you must always give a timeoutMillis.
If the condition is not met in time,
the action is not dispatched, and the condition stops being checked.
You can use onTimeout to decide what happens in that case:
dispatchWhen(
new BuyStock('IBM'),
(state) => state.stocks.getPrice('IBM') >= 100,
{
timeoutMillis: 60 * 1000, // 1 minute.
onTimeout: () => dispatch(new ShowMessage('IBM did not reach 100.')),
},
);
If you don't provide onTimeout, the timeout is simply logged.
You can use dispatchWhen from anywhere:
- With the store:
store.dispatchWhen(...) - Inside actions:
this.dispatchWhen(...) - In components: with the
useDispatchWhenhook, or withuseStore().dispatchWhen(...)
function BuyWhenCheap() {
const dispatchWhen = useDispatchWhen();
return (
<Button onClick={() => dispatchWhen(
new BuyStock('IBM'),
(state) => state.stocks.getPrice('IBM') <= 90,
{ timeoutMillis: 0 }, // No timeout.
)}>
Buy IBM when it's 90 or less
</Button>
);
};
Note the wait is not cancelled when the component unmounts.
In tests
The waitCondition function is also very useful in tests.
You can dispatch actions that perform some complex stuff,
and then simply wait until the state reaches the exact condition you want to test for.
In the following example, we dispatch a LogInUser action
and then wait until the user is logged in:
const store = new Store<State>({ initialState: new State() });
expect(store.state.user.isLoggedIn).toBe(false);
dispatch(new LogInUser("Mary"));
await store.waitCondition((state) => state.user.isLoggedIn, { timeoutMillis: 1000 });
expect(store.state.user.name).toBe("Mary");
Another useful fact is that the waitCondition function
returns you the exact action (called the "trigger action")
that changed the state to meet the condition.
This can be helpful in tests when you need to assert
the specific action that caused the state change:
let action = await store.waitCondition(
(state) => state.name == "Bill",
{ timeoutMillis: 1000 },
);
expect(action instanceof ChangeNameAction).toBe(true);
In tests, use a short timeoutMillis, shorter than your test runner's own timeout
(5 seconds by default in Jest and Vitest). This way, if the condition is never met,
the test fails with a clear TimeoutException, instead of a generic test timeout.
waitAllActions
You can use the waitAllActions function to wait until no actions are in progress:
// Initially we have some IBM stocks
expect(store.state.portfolio).toEqual(['IBM']);
// We'll sell IBM and buy TSLA, dispatching actions in parallel
dispatch(new SellAction('IBM'));
dispatch(new BuyAction('TSLA'));
// We wait until ALL actions finish
await store.waitAllActions([]);
// Now we should have TSLA stocks and no IBM stocks
expect(store.state.portfolio).toEqual(['TSLA']);
In the code above, dispatching both actions in parallel could also have been done like this:
dispatchAll([
new SellAction('IBM'),
new BuyAction('TSLA')
]);
Instead of waiting for all actions to finish, you can wait for specific actions to finish. In this case, you'll need a reference to the actions you want to wait for, before dispatching them:
// Initially we have some IBM stocks
expect(store.state.portfolio).toEqual(['IBM']);
// We'll sell IBM and buy TSLA, dispatching actions in parallel
var action1 = new SellAction('IBM');
var action2 = new BuyAction('TSLA');
dispatchAll([action1, action2]);
// We wait until both actions finish
await store.waitAllActions([action1, action2]);
// Now we should have TSLA stocks and no IBM stocks
expect(store.state.portfolio).toEqual(['TSLA']);
Note, in this case we could have used dispatchAndWaitAll instead:
// Initially we have some IBM stocks
expect(store.state.portfolio).toEqual(['IBM']);
// We'll sell IBM and buy TSLA, dispatching actions in parallel,
// and then wait until both actions finish
await dispatchAndWaitAll([
new SellAction('IBM'),
new BuyAction('TSLA')
]);
// Now we should have TSLA stocks and no IBM stocks
expect(store.state.portfolio).toEqual(['TSLA']);
Or, if we don't mind that the actions run in series
(one after the other, instead of running in parallel),
we could have used dispatchAndWait:
// Initially we have some IBM stocks
expect(store.state.portfolio).toEqual(['IBM']);
// We'll sell our IBM and buy TSLA, separately, and wait for each
await dispatchAndWait(new SellAction('IBM'));
await dispatchAndWait(new BuyAction('TSLA'));
// Now we should have TSLA stocks and no IBM stocks
expect(store.state.portfolio).toEqual(['TSLA']);
Only in tests you should use waitAllActions([]) to wait until no actions are in progress.
Do not use it in production, because it can easily cause a deadlock. In tests it's acceptable,
because the test will fail and you can set a timeout.
However, you can use something like waitAllActions([myAction]) in production,
to wait for specific actions to finish.
That's safe in production, as long as you're waiting for actions you just dispatched.
When the promise resolves, you get back the set of actions being dispatched that met the condition, as well as the action that triggered the condition by being added or removed from the set.
waitAllActionTypes and waitActionType
You can use the waitAllActionTypes function to wait until all actions of the given type
are not in progress:
-
If no action of the given types is currently in progress when the function is called, and parameter
completeImmediatelyisfalse(the default), this function will throw an error. -
If no action of the given type is currently in progress when the function is called, and parameter
completeImmediatelyistrue, the promise completes immediately and throws no error. -
If any action of the given types is in progress, the promise completes only when no action of the given types is in progress anymore.
For example:
// Initially we have some IBM stocks
expect(store.state.portfolio).toEqual(['IBM']);
// We'll sell our IBM and buy TSLA
dispatch(new SellAction('IBM'));
dispatch(new BuyAction('TSLA'));
// We wait until the above action types finish
await store.waitAllActionTypes([BuyAction, SellAction]);
// Now we should have TSLA stocks and no IBM stocks
expect(store.state.portfolio).toEqual(['TSLA']);
The waitActionType function is very similar to the above waitAllActionTypes,
but it waits for a single action type.
Note it does not wait for an action of the given type to be dispatched.
To do that, use waitAnyActionTypeFinishes.
The important difference is that it returns the action that caused the condition to be met.
You can use this returned action to check its status, for example, to assert it failed:
dispatch(new MyAction());
let action = await store.waitActionType(MyAction);
expect(action?.status.isCompletedOk).toBe(false);
expect(action?.status.isCompletedFailed).toBe(true);
expect(action?.status.originalError).toBeInstanceOf(UserException);
Note the returned action may be null. This happens when the promise completes immediately
(with completeImmediately: true), or when it times out and you passed an onTimeout.
waitAnyActionTypeFinishes
You can use the waitAnyActionTypeFinishes function to wait until any action of the given
types finishes dispatching.
This function is different from the other similar functions above, because it does NOT complete immediately if no action of the given types is in progress. Instead, it waits until an action of the given types finishes dispatching, even if they were not yet in progress when the function was called.
It's useful when the actions you are waiting for are not yet dispatched when you call this
function. For example, suppose action StartAction starts a complex process that takes some time
to run and then eventually dispatches an action called MyFinalAction.
In this case, you can use waitAnyActionTypeFinishes to wait for MyFinalAction to
eventually dispatch and finish:
dispatch(new StartAction());
await store.waitAnyActionTypeFinishes([MyFinalAction]);
This function also returns the action that completed the promise,
which you can use to check its status.
For example, if you want to assert that MyFinalAction failed by throwing a UserException:
dispatch(new StartAction());
let action = await store.waitAnyActionTypeFinishes([MyFinalAction]);
expect(action.status.isCompletedOk).toBe(false);
expect(action.status.isCompletedFailed).toBe(true);
expect(action.status.originalError).toBeInstanceOf(UserException);
waitActionCondition
You can use the waitActionCondition function to wait until the set of actions in progress,
and the trigger action, meet the given condition.
The condition function should return true when the condition is met, and false otherwise:
await store.waitActionCondition(
(actionsInProgress, triggerAction) => { /* Return true or false */ }
);
The trigger action is the action that just entered the set (by being dispatched), or just left the set (by finishing dispatching).
The condition is only checked when some action is dispatched or finishes dispatching. It's not checked every time action statuses change.
When the promise resolves, you get back the set of actions being dispatched that met the condition, as well as the trigger action. This may be useful in tests.
The actionsInProgress set your condition gets is a copy.
Changing it doesn't affect the store.
The set you get back when the promise resolves is also a copy, taken when the condition was met,
so it doesn't change when other actions are later dispatched or finish.
Optional parameters
Most wait functions above accept these optional parameters:
-
If
completeImmediatelyis false (the default), the function will throw an error if the condition was already true when the function was called. Otherwise, the promise will complete immediately and throw no errors. -
The
timeoutMillissets the maximum time to wait for the condition to be met. If it's not met in time, the promise rejects with aTimeoutException. By default, it's 3 seconds, which is shorter than the default test timeout of Jest and Vitest (5 seconds). This way, a wait that never completes fails the test with a clearTimeoutException. If your tests are slower, you can change this default globally, for example:TimeoutException.defaultTimeoutMillis = 10_000. To wait with no timeout, make it0or-1. For example:{ timeoutMillis: 0 }. (NotewaitConditionanddispatchWhenhave no default timeout. You must always give one.) -
The
onTimeoutcallback is called if the timeout expires. When you provide it, the promise resolves instead of rejecting with aTimeoutException. In this case, there is no trigger action, so the functions that return one returnnull. IfonTimeoutthrows an error, the promise rejects with that error.