Hi there devs! 👋
Today we will create one of the most helpful tools for our state management library - Scenario. In this part we will only cover basic, yet most useful strategy - fork.
Fork strategy means that every trigger is handled, thus at the same point of time, few async callbacks may be running, which can lead to potential race conditions, so be careful.
If you're curious about all the capabilities of scenario, head straight to Awai docs.
The scenario we will implement should receive a trigger and a callback. Callback will be executed each time scenario is triggered.
Similarly to the tools we created in previous parts, scenario will emit events. Its events will be started
, fulfilled
and rejected
.
Things are getting way more interesting at this point, aren't they?
Scaffold of our tool:
const scenario = (trigger, callback) => {
const events = {
started: new AwaiEvent(),
fulfilled: new AwaiEvent(),
rejected: new AwaiEvent(),
};
const run = () => {
// to be implemented
};
run();
return { events };
};
In order to implement the run
function we can re-use and modify the code we implemented in previous part, where we were listening to dependencies changes in selector.
Wait, wait, wait! 🤔 This all sounds great, but what can be a trigger? It can be at least our thennable AwaiEvent. But let's make the tool even more powerful, so that trigger can be any Promise! Do you already see where is it going? 😎
At this point our scenario will have two overloads, which will be enough for implementing most of other utils up until the end of this series.
// AwaiEvent trigger (from previous chapters)
scenario(awaiEvent, callback);
// factory that returns a Promise, which will trigger a scenario
scenario(() => promise, callback);
As you can see, trigger can be a function returning a promise. Once promise is resolved, the scenario callback will start.
No more words, let's implement.
const getTriggerPromise = (trigger) => typeof trigger === 'function' ? trigger() : trigger;
const run = () => {
getTriggerPromise(trigger).then((event) => {
events.started.emit({ config, event });
Promise.resolve(callback(event))
.then((result) => {
events.fulfilled.emit({ event, result });
})
.catch((error) => {
events.rejected.emit(error);
});
run();
});
};
In getTriggerPromise
helper function we check the trigger - if it is a function, we call it and expect it to return a promise, otherwise we just return a trigger (which is, according to our overloads, an AwaiEvent
).
It is quite remarkable that we pass the result of a trigger to callback as an argument. Now scenario can play the role of event listener! 🧐
Notice how we wrapped callback
call with Promise.resolve
. Since our callback may be both sync and async, this Promise.resolve
is used to ensure that we have a promise at this place.
If this promise is resolved, it means the callback has successfully returned a value, which we then emit in fulfilled
event. If it rejects, we emit rejected
error.
I hope you understand everything so far. If not, feel free to ask questions.
Full util code:
const getTriggerPromise = (trigger) => {
return typeof trigger === 'function' ? trigger() : trigger;
};
const scenario = (trigger, callback) => {
const events = {
started: new AwaiEvent(),
fulfilled: new AwaiEvent(),
rejected: new AwaiEvent(),
};
const run = () => {
getTriggerPromise(trigger).then((event) => {
events.started.emit({ config, event });
Promise.resolve(callback(event))
.then((result) => {
events.fulfilled.emit({ event, result });
})
.catch((error) => {
events.rejected.emit(error);
});
run();
});
};
run();
return { events };
};
Now we can use this tool to listen to any state changes
const nameState = state('John');
setTimeout(() => nameState.set('Andrew'), 1000);
scenario(nameState.events.changed, (name) => {
console.log(`New user name: ${name}`);
});
But our tool is so powerful, let's write a bit more advanced example. We can notify the API as soon as user name is changed, and let's also add some logging without polluting callback code.
const nameChangeScenario = scenario(nameState.events.changed, async (name) => {
await fetch('/api/user-name', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});
});
scenario(nameChangeScenario.events.started, () => {
console.log('User name changed. We have started API notifying process.');
});
scenario(nameChangeScenario.events.fulfilled, () => {
console.log('Server has been notified about user name change');
});
scenario(nameChangeScenario.events.rejected, (error) => {
console.log(`There were problems while updating user name. Error: ${String(error)}`);
});
Yes, we can use scenario to listen to another scenario events! 🤫
You probably think I forgot about promise factory overload? No way, that's my favorite part.
For better demonstration we need a wait
util which just resolves a promise after specified amount of time.
const wait = (delay) => new Promise((resolve) => setTimeout(resolve, delay));
Here we go, the easiest fancy scenario, which runs every second:
scenario(() => wait(1000), () => {
console.log('One more second passed');
});
Under the hood, we run a trigger function that returns a Promise which will be resolved after one second. Right after that, scenario callback will run (logs text to console), and start scenario listener again, so that we will get a new promise which will be resolved after one second, and so forth.
As the last example, we will combine some promises. So that scenario is triggered either every second, or when name is changed.
scenario(
() => Promise.race([
wait(1000),
nameState.events.changed
]),
() => {
console.log('This callback is called every second, but also when nameState is changed');
}
);
Summary
Enjoyed this tutorial? Stay tuned for further parts.
Do you see the potential of this tool? Any ideas how you can use Scenario
in your projects? Share your thoughts in comments.
As usual, here is the playground where you can play with the tool we created together.
See you in next chapters!
Top comments (0)