DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’»

Igor Kamyshev for Effector

Posted on

The best part of Effector

Π‘Ρ‚Π°Ρ‚ΡŒΡŽ ΠΌΠΎΠΆΠ½ΠΎ ΠΏΡ€ΠΎΡ‡ΠΈΡ‚Π°Ρ‚ΡŒ Π½Π° русском

Effector is a lightweight and performant state manager. It is UI frameworks agnostic, predictable and easy to use. Almost half a year ago, Aviasales team has started migration from RxJS to Effector. While I was investigating this library, I found a completely unexpected feature. It changed my mind about Effector.

Disclaimer

In my opinion, in modern applications, we have to write domain-specific logic with reactive primitives. Such primitives do not exist in JavaScript, so we must use a library for it. I prefer to use a state manager as a dependency zero and bound it with core application logic.

The Problem

Creating complex business scenarios frequently includes waiting for all computations to be completed. Moreover, if an application is built over event-oriented architecture, it will be quite difficult to define the end of events processing. In the common case, we need this opportunity in two situations. The first one is widely used, any good application requires it. The second one is more specific, but it is pretty important too.

Tests

In my everyday work, I write two types of tests: unit tests and domain-logic tests. Unit tests do not relate to state managers and the business logic of the application. However, the domain logic tests can lead to a problem.

In domain-specific scenarios we commonly use the same pattern β€” emit some event, wait for application reactions and relay on result state, e.g., πŸ‘‡

describe('User flow', () => {
  test('should set default currency after login', async () => {
    emitEvent('Login', { login, password })

    // ... wait

    expect(userSettings.currency).toBe('THB')
  }) 
})
Enter fullscreen mode Exit fullscreen mode

If the whole authorization flow is synchronous, we do not have any problems β€” scenarios will end right after the first event emitting. Nonetheless, in real applications, almost all scenarios are completely asynchronous.

Furthermore, even in simple cases we can find a situation where simple waiting for some promise is impossible, e.g., πŸ‘‡

async function fetchCurrency({ token }) {
  const { currency } = await request('/settings', { token })

  return currency ?? 'THB'
}

async function login({ login, password }) {
  const token = await request('/login', { login, password })

  // can't wait for resolve
  // it's a domain-specific logic πŸ€·β€β™‚οΈ
  fetchCurrency({ token })
    .then(currency => setLocalCurrency(currency))

  return token
}
Enter fullscreen mode Exit fullscreen mode

Many business scenarios contain asynchronous operations, and some of these operations can cause other asynchronous operations, etc.

Image 24.09.2021, 10-30.01f66b7057964c02ae08b25d2f2342e1

The only solution for this case to wait for the end of scenario is to emit a special event. Thereafter, we can rewrite our tests this way πŸ‘‡

describe('User flow', () => {
  test('should set default currency after login', async () => {
    emitEvent('Login', { login, password })

    await waitForEvent('Login/Finished')

    expect(userSettings.currency).toBe('THB')
  }) 
})
Enter fullscreen mode Exit fullscreen mode

But this solution prevents us from writing complex and well-designed scenarios. We will not be able to inverse dependencies and remove knowledge of the outer world from the scenario. It leads to gigantic scenarios, and engineers cannot keep it in their heads.

In an ideal case, I would not like to edit the source code of my scenario to satisfy tests.

describe('User flow', () => {
  test('should set default currency after login', async () => {
    emitEvent('Login', { login, password })

    await waitForAllComputationsFinished()

    expect(userSettings.currency).toBe('THB')
  }) 
})
Enter fullscreen mode Exit fullscreen mode

πŸ‘† this test case knows nothing about the internal structure of the login process. When you read it, you immediately understand what is happening β€” the user starts the login flow, and after the whole all process currency is changed to Thai bahts.

SSR

SSR (Server-side rendering) is a process of creating an HTML string based on current state of the application. The HTML is sent to the browser and the user can start using the site before JS loads. It is a good way to improve user experience and SEO.

Let us dive into the rendering process. Right after the user has opened a page, the application should request data, wait for the end of computation and pass the whole state to a UI-framework to create an HTML-string. This process looks very similar to the test of domain-specific scenarios (here we recognize β€œthe user has opened a page” as a scenario).

async function renderAppOnServer(route) {
  const store = createStore()

  emitEvent('Route/changed', { route })

  // ... wait

  return renderAppToString(store)
}
Enter fullscreen mode Exit fullscreen mode

All modern UI frameworks have an opportunity to create an interface as a function of the state. In this article, I will not dive deep into the details of a particular UI framework, let us use the magical generic function renderAppToString. It accepts the application state and returns an HTML-string. Implementation of this function is trivial for any popular UI framework.

So, let us imagine the ideal world again! In the ideal world, the application ought to wait till the computations are completed on state and render a string πŸ‘‡

async function renderAppOnServer(route) {
  const store = createStore()

  emitEvent('Route/changed', { route })

  await waitForAllComputationsFinished()

  return renderAppToString(store)
}
Enter fullscreen mode Exit fullscreen mode

Classic Solutions

We have identified a problem of waiting for computations to be completed, so let us see how classic state managers solve it. I have an experience with redux and MobX, so I will talk about it. If your favourite state manager can handle this case simpler, please tell me about it on Twitter.

Redux

First, redux itself does not have any mechanism to handle asynchronous actions and side effects. A common application uses something like redux-saga or redux-thunk (now it is a part of @reduxjs/toolkit) for it.

The simplest way to detect the end of computations is to add the new action β€œcomputations is ended”. It is a simple and working solution, but it has a fatal problem β€” you (as an engineer) should think about β€œend-of-computations” actions in any scenario, you should adopt a domain-specific logic to it. Moreover, if the scenario becomes more complex, this approach can destroy its readability.

Another option is to put the whole scenario logic to a single entity (thunk, saga, whatever). In this case, we can just wait for the end of the entity. E.g., thunk returns a simple promise from a dispatch-call, so we can wait for resolve. Sagas are based on generators and for handling this case they have a special library β€” redux-saga-test-plan.

In conclusion, redux-world has a solution. However, this solution is not simple and enjoyable πŸ€·β€β™‚οΈ also, it does not work in complex cases.

MobX

Actually, MobX uses the same techniques as Redux for the solution of our problem. E.g., we can just add a boolean property to the store and wait for its changes πŸ‘‡

describe('User flow', () => {
  test('should set default currency after login', async () => {
    userStore.login({ login, password })

    await when(() => userStore.done)

    expect(userStore.currency).toBe('THB')
  }) 
})
Enter fullscreen mode Exit fullscreen mode

So, it is working, except for one thing. We cannot use this solution for a complex scenario, if it works with many stores.

Moreover, we can put the whole scenario in single asynchronous function, it will simplify the tests πŸ‘‡

describe('User flow', () => {
  test('should set default currency after login', async () => {
    await userStore.login({ login, password })

    expect(userStore.currency).toBe('THB')
  }) 
})
Enter fullscreen mode Exit fullscreen mode

But it cannot cover complex scenarios.
In conclusion, MobX-world has a solution. However, this solution is not simple and enjoyable πŸ€·β€β™‚οΈ also, it does not work in complex cases.

Effector-world

To understand this part of the article, it is better to read Effector’s documentation first.
Effector has a game-changer feature β€” Fork API. To understand it, we should talk about one important concept β€” scope.

The scope is an independent copy of the whole application. You can run any logic over a specific scope, and it will not affect any other scope. Let us read some code πŸ‘‡

const loginFx = createEffect(/* e.g., request to backend */)

// Event of currency change
const changeCurrency = settings.createEvent()

// Currency store
const $currency = createStore()
  // just save the payload of event to a store
  .on(changeCurrency, (_, newCurrency) => newCurrency)

sample({
  // After login request successfully ends
  source: loginFx.doneData,
  // get a currency from a result
  fn: ({ settings }) => settings.currency ?? 'thb',
  // and can event changeCurrency with it
  target: changeCurrency,
})
Enter fullscreen mode Exit fullscreen mode

Now, we can fork this application and get an independent copy of the application β€” scope.

Tests

Let us write a test for this scenario β€” after a user has logged in without specified currency, they should get Thai bahts as a currency.

describe('User flow', () => {
  test('should set default currency after login', () => {
    loginFx({ login, password })

    expect($currency.getState()).toBe('THB')
  }) 
})
Enter fullscreen mode Exit fullscreen mode

πŸ‘†this test will fail, of course. It does not include waiting of the computation end.

In Effector-world, we can fix it with a special function allSettled. It starts a unit (event or effect) and waits for end of computations on the specified scope. To get a state of store in particular scope, we can use scope.getState method.

describe('User flow', () => {
  test('should set default currency after login', async () => {
    // Fork application and create an isolated scope
    const scope = fork()

    // Start logixFx on the scope
    // and wait for computations env
    await allSettled(loginFx, {
      params: { login, password },
      scope,
    })

    // Check a store state on the scope
    expect(scope.getState($currency)).toBe('THB')
  }) 
})
Enter fullscreen mode Exit fullscreen mode

So, we have written a test for domain-specific scenarios, and we did not edit the scenario for it. In my opinion, it is the most important feature of Effector.

One more thing

Yeah, you can notice that this test case executes a real effect-handler. It is a good note, we should mock the handler of loginFx and return some test data.

We can do this with test-runner mechanisms, e.g., replace imports or monkey-patch internal state of loginFx. I dislike these ways. It is too fragile because tests get a knowledge of internal structure of scenario.

Fork API helps us in this case, too. It has a built-in mechanism to replace any effect-handler in a specific scope. Let us improve our test-case πŸ‘‡

describe('User flow', () => {
  test('should set default currency after login', async () => {
    const scope = fork({
      handlers: new Map([
        // Replace original handler in this scope
        [loginFx, jest.fn(() => ({ settings: null }))]
      ])
    })

    await allSettled(loginFx, {
      params: { login, password },
      scope,
    })

    expect(scope.getState($currency)).toBe('THB')
  }) 
})
Enter fullscreen mode Exit fullscreen mode

This feature helps us to replace any handlers without runtime modifications in specific test-case.

Be careful! To use this feature, you should set up official babel-plugin.

SSR

The second use-case of Fork API is an SSR. There are two reasons for it.

First, for SSR, the application executes in the Node.js environment. This environment can handle a huge amount of parallel requests. It means that we should isolate different instances of application between requests. If Effector-world, we should just fork application for any requests. So, each of the requests has a personal scope.

The second reason is allSettled. After requests, the application should start data-fetching and after the finish, it ought to render an interface based on specific scope.

E.g., this small application must load the counter from the internet and show it on the page:

const routeChanged = createEvent()

const fetchUsersFx = createEffect(/* some request */)

const $userCount = stats.createStore()
  .on(fetchUsersFx.doneData, (_, newCount) => newCount)

guard({
  // When route changed
  clock: routeChanged,
  // if the new route is main page
  filter: (route) => route === 'main',
  // load users data
  target: fetchUsersFx,
})
Enter fullscreen mode Exit fullscreen mode

πŸ‘† this scenario knows nothing about context. An application does not care whether it is executed in the user’s browser or in the Node.js environment on the server.

In this case, we can easily add SSR to the application. We have to fork the application on every request and execute computations using the isolated scope πŸ‘‡

async function renderAppOnServer(route) {
  // Create a scope for a specific request
  const scope = fork()

  // Emit an event about route changes
  // and wait for all computations
  await allSettled(routeChanged, {
    params: route,
    scope,
  })

  // All UI-framework's job
  return renderAppToString(scope)
}
Enter fullscreen mode Exit fullscreen mode

Effector has adapters for UI-frameworks to simplify scope-based rendering. Read the documentation for details.

So, we have added an SSR to the application without changing the code. And, in my opinion, it’s the second great feature of Effector.

So?

So, we decided to use Effector because it is based on multi-stores. It helps to create more solid applications and develop them in large teams. I thought, that it was a nice and interesting tool, but I was not a zealot of Effector.

However, after a month of development I had found Fork API, and it changed everything. Now I am confident that it is the best way to write applications without much effort.


Waiting for your comments and feedback πŸ€—

Top comments (0)

πŸ”  Find your favorite font

Β 
You can change your default font in Settings.