Effector Tutorial #2

In this episode we are going to refactor our code a little bit, add the ability to both increment and decrement the counter, ability to do so by arbitrary amounts, let the counter start with a predefined value, and we will also add some tests. You can follow along in the blog post, or check out the episode3 tag in the repository, which I'll link in the description. Let's get started.

Clean-up

Ok, to start we will clean up our code a little bit. The old hooks-based code has got to go, and we'll remove all of the bundler logos. We love you vite, but this is about another star player, namely – Effector.

The code for App.tsx should look something like this

function App() {
  return (
    <>
      <Counter counter={counterOne} />
      <Counter counter={counterTwo} />
    </>
  );
} 

We will also clean up unused imports, and so on…

Now I want to do some clean-ups in the model file. I like to have my factory names capitalized, and my instances to start with a lower case, makes it easier to tell what's the blueprint and what's the actual item that the factory produces.

export const CounterFactory = ...

Now, it would be better if the output type was defined and exported here, so that everyone can just grab it trivially. Let's add it to our model file

export type CounterModel = ReturnType<typeof CounterFactory>;

Let's go back to the App.tsx and fix the import errors and naming errors.

import { CounterFactory, type CounterModel } from './model';

Now let's fix the invoke-actions.

const counterOne = invoke(CounterFactory);
const counterTwo = invoke(CounterFactory);

Let us make the component declaration a bit nicer

export const Counter: FunctionComponent<{ counter: CounterModel }> = ({ counter }) => ...

The rest remains the same.

Additions

Great, we got rid of the unnecessary things, reduced the noise and now we can focus on adding functionality. For starters, let's make the counter start from an arbitrary value, since sometimes we might want not start with 0. Let's make the following change in our model file.

export type CounterSettings = {
  initialValue?: number;
}

export const CounterFactory = (( initialValue = }: CounterSettings) => {
   const $counter = createStore(initialValue);
   
   ...
}

Now we can try to set up our second counter with something other than 0.

In our App.tsx let's modify the invocations of the factories.

const counterOne = invoke(CounterFactory, {});
const counterTwo = invoke(CounterFactory, { initialValue: 10 });

If we were to refresh the page, the second counter should start at 10 instead of 0.

Now let's look at having the ability to decrement the counter. It is going to be similar to incrementing it. In our model.ts let's add the following:

const decrement = createEvent();

sample({
  source: $counter,
  clock: decrement,
  fn(current) {
    return current - 1;
  },
  target: $counter
});

and let's not forget to add that to the factory's output

return {
  $counter,
  increment,
  decrement
}

Now we can go back to our App.tsx file and wire up the button. Let's get the decrement event from the factory

const { $counter, increment, decrement } = useUnit(counter);

And let's add the button and re-shuffle the card a bit

...
return (
  <div className="card">
    <button onClick={() => decrement()}>-</button>
    <span>Counter value is {$counter}</span>
    <button onClick={() => increment()}>+</button>
  </div>
);

Now we can verify that our increment and decrement work.

With this in mind, how about we add some functionality to be able to increment and decrement by an arbitrary amount.

There are a few different ways we can do this, but we'll follow the pattern we already have, and maybe get fancier in the later episodes of this series.

For now we need to modify the event payload type. By default any event created is having a parameter type of void, which translates to “no parameters necessary”. If we wish to pass them, we should be a bit more explicit. Let's modify the increment and decrement events in our model file.

const increment = createEvent<number>();
const decrement = createEvent<number>();

Now both events require a payload, but where would we be able to use this payload?

Well for that to work we can pull the payload in the second argument in our sample's fn function.

The first argument to fn will always point to the source (if present), and the second one will contain the payload for the clock (if present). There are many different ways one can slice and dice sample, and there's a nice little matrix in the documentation. So let's make those changes now.

sample({
  source: $counter,
  clock: increment,
  fn(current, increment) {
    return current + increment;
  },
  target: $counter
})

sample({
  source: $counter,
  clock: decrement,
  fn(current, decrement) {
    return current - decrement;
  },
  target: $counter
})

And now, because we changed the arity of both our events, we'd have to fix up our template. So let's go back to App.tsx and make changes there

<button onClick={() => decrement(1)}>-</button>
...
<button onClick={() => increment(1)}>+</button>

We might as well add more buttons with different values

<button onClick={() => decrement(5)}>-5</button>
<button onClick={() => decrement(1)}>-</button>
...
<button onClick={() => increment(1)}>+</button>
<button onClick={() => increment(5)}>+5</button>

Now we can increment or decrement our counters 5 times faster

Tests

Now let's add tests. This will show a small sliver of the the goodness that is testing effector business logic. First we need to do some prep

npm install -D vitest

and now let's also add the test command to our package.json

{
   "scripts": {
     "test": "vitest"
   }
}

Let's create our test file

touch src/model.test.ts

And let's start writing some tests

import { beforeEach, describe, expect, test } from "vitest";
import { CounterFactory, type CounterModel } from "./model";

import { allSettled, fork } from "effector";
import { invoke } from "@withease/factories";

Let's make sure that our world starts in correct state by writing some initialization tests

describe("Initialization tests", () => {
  test("Counter without initial value is initialized to 0", () => {
    const subject = invoke(CounterFactory, {});
    const scope = fork();

    expect(scope.getState(subject.$counter)).toBe(0);
  });

  test("Counter with initial value is initialized to that value", () => {
    const subject = invoke(CounterFactory, { initialValue: 10 });
    const scope = fork();

    expect(scope.getState(subject.$counter)).toBe(10);
  });
});

Let's run our test suite so we can monitor our progress

npm run test

Okay, everything seems to be going well, let's test the functionality of the increment and decrement

describe("Incrementing behaviours work", () => {
  let subject: CounterModel;
  beforeEach(() => {
    subject = invoke(CounterFactory, {});
  });

  test("Incrementing by 1 works", async () => {
    const scope = fork();

    await allSettled(subject.increment, { scope, params: 1 });

    expect(scope.getState(subject.$counter)).toBe(1);
  });

  test("Incrementing by 5 works", async () => {
    const scope = fork();

    await allSettled(subject.increment, { scope, params: 5 });

    expect(scope.getState(subject.$counter)).toBe(5);
  });
});

So, there are few things to pay attention to:

Let's add the complementary part for decrement to ensure that we test everything

describe("Decrementing behaviours work", () => {
  let subject: CounterModel;
  beforeEach(() => {
    subject = invoke(CounterFactory, {});
  });

  test("Decrementing by 1 works", async () => {
    const scope = fork();

    await allSettled(subject.decrement, { scope, params: 1 });

    expect(scope.getState(subject.$counter)).toBe(-1);
  });

  test("Decrementing by 5 works", async () => {
    const scope = fork();

    await allSettled(subject.decrement, { scope, params: 5 });

    expect(scope.getState(subject.$counter)).toBe(-5);
  });
});

Combine store data

As a last little thing we can see how we can use the both counters to have a quick way to see the total count

I'm not going to build a factory at this point as I'd like to wrap this up, but here's what we can do. In our App.tsx file we can add a new component and a derived store


const $counterTotal = combine(counterOne.$store, counterTwo.$store, (a, b) => a + b);

const Total: FunctionComponent = () => {
  const total = useUnit($counterTotal);
  
  return (<div>Total: ${total}</div>);
}

And now we add it to our main component

function App() {
  return (
    <>
      <Counter counter={counterOne} />
      <Counter counter={counterTwo} />
      <Total />
    </>
  );
}

Once we get this we can see our total sum of both counters. And if we have our re-render overlay from react dev tools, we can see that only the parts that were affected by the computation are getting re-rendered. All that without having to do useMemo

In the next episodes of this series we'll start exploring more aspects of effector. Make sure you check out the blog post which should contain everything from this video pretty much verbatim, get the code from the github repository, and leave a comment if you have any questions that you'd like answered.

I will see you in the next one. I hope you have a great rest of your day. Good bye.