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:
- A few new players have entered the arena, namely
fork
and allSettled
. fork
allows us to literally fork the world. And while it's doing so, it can also tweak the world to your liking. Imagine being able to say fork({ values: { $bankBalance: 1_000_000_000 }, handlers: { buyCarFx: () => return 'Maybach' }})
? Wouldn't that be nice? Probably not, but hey, a man can dream. We aren't using that functionality at the moment, we just get the exact copy.
allSettled
allows us to push that first domino that starts the computation graph. It waits until all computations (and asynchronous effects, for that matter) resolve. How cool is that? Once they all have resolved – we can poke at the state of the world and make sure it matches our expectations.
- In the case of our incrementing – that first domino is the
increment
event. We trigger it with various values and observe that the world has settled in a new state and our expectations were correct
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.