DEV Community

Yuriy Yakym
Yuriy Yakym

Posted on

Implementing State management library - Awai Scenario

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.

Fork Scenario diagram

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 };
};
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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();
  });
};
Enter fullscreen mode Exit fullscreen mode

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 };
};
Enter fullscreen mode Exit fullscreen mode

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}`);
});
Enter fullscreen mode Exit fullscreen mode

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)}`);
});
Enter fullscreen mode Exit fullscreen mode

Yes, we can use scenario to listen to another scenario events! 🀫

Inception

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));
Enter fullscreen mode Exit fullscreen mode

Here we go, the easiest fancy scenario, which runs every second:

scenario(() => wait(1000), () => {
  console.log('One more second passed');
});
Enter fullscreen mode Exit fullscreen mode

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');
  }
);
Enter fullscreen mode Exit fullscreen mode

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)