sleepy

TOPICS

IntroductionReact (with MobX)ArchitectureModelsNetworkingData TransformersStoresViews & View Models

Stores

Responsibility

Stores are the slice that hold global state as well as the methods that can modify that global state and are the entry into the business logic of the app. Our view models will take stores as dependencies.

Let's create our current weather store!

Designing a Store

We always want to start by setting up an interface. I find designing the interface first helps me think more in what I would ideally want it to look like and not get hung up on implementation details.

We know our app will need to get weather by zip code as well as the current location's weather. Let's start with the current location this time.

1 2 3 4 interface WeatherStorable { /** Weather of the browser's current location, if available */ readonly currentLocationWeather?: Weather; }

And then we'll need a way to trigger fetching the current location's weather. I know that the browser will return longitude and latitude so I'll set up my method to take those parameters

1 2 3 4 5 6 7 interface WeatherStore { /** Weather of the browser's current location, if available */ readonly currentLocationWeather?: Weather; /** Updates the currentLocationWeather */ getWeatherForLocation(longitude: number, latitude: number): Promise<void>; }

What I really like about this pattern is that the method to fetch the information and the information itself are completely separate entities. Our app can refresh the data as often as needed, and view models can subscribe to the observable currentLocationWeather and always be updated with the latest data, even if the view model itself did not trigger updating the data.

Why does the getWeatherForLocation method return a Promise if the data is void? Because we may want to know that the API call is happening, and be able to catch any errors, but we should reference the data from the source of truth which is the currentLocationWeather property.

Now let's create the store that implements WeatherStorable.

1 2 3 4 5 6 7 8 9 10 11 class WeatherStore implements WeatherStore { @observable currentLocationWeather?: Weather; constructor() { makeObservable(this); } getWeatherForLocation(longitude: number, latitude: number): Promise<void> { // Implement } }

Why is the currentLocationWeather property observable and not readonly? Because we'll be referencing our WeatherStore via its interfaces, and the currentLocationWeather property is marked readonly in the interfaces, TypeScript will block modifying it directly, so I don't need to mark it readonly in the implementation (this is not always possible in other languages).

Now we need a way to actually fetch this data, so let's inject our data transformer. Notice that it is injected with the type of its interface, not its implementation.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 class WeatherStore implements WeatherStore { @observable currentLocationWeather?: Weather; private readonly dataTransformer: OpenWeatherDataTransformable; constructor(dataTransformer: OpenWeatherDataTransformable) { makeObservable(this); this.dataTransformer = dataTransformer; } getWeatherForLocation(longitude: number, latitude: number): Promise<void> { // Implement } }

Now let's make the call to our data transformer and update the observable property

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 class WeatherStore implements WeatherStorable { @observable currentLocationWeather?: Weather; private readonly dataTransformer: OpenWeatherDataTransformable; constructor(dataTransformer: OpenWeatherDataTransformable) { makeObservable(this); this.dataTransformer = dataTransformer; } getWeatherForLocation(longitude: number, latitude: number): Promise<void> { return this.dataTransformer.getWeatherForCoordinates(longitude, latitude).then((weather) => { this.updateCurrentWeatherLocation(weather); return Promise.resolve(); }); } @action private updateCurrentWeatherLocation(weather?: Weather): void { this.currentLocationWeather = weather; } }

Notice the observable property currentLocationWeather is updated within another method called updateCurrentWeatherLocation which is decorated by @action. This is because decorating getWeatherForLocation with @action does not run the Promise handlers in the MobX action so I do it in a separate method. MobX also provies a runInAction method, which you can use as well. It would look like this.

1 2 3 4 5 6 7 8 9 10 11 12 getWeatherForLocation(longitude: number, latitude: number): Promise<void> { return this.networker.getWeatherForCoordinates(longitude, latitude).then((response) => { const kelvin = response.data.main.temp; const celsius = Math.floor(kelvin - 273.15); const weather = { temperature: celsius, city: response.data.name, }; runInAction(() => (this.currentLocationWeather = this.transformWeatherResponse(response))); return Promise.resolve(); }) }

These work exactly the same. I usually prefer the separate method approach because 1) I find it easier to read at a glance and 2) if there are multiple ways the currentLocationWeather can be updated, we can always go through that method and there's no way to accidentally forget to run it in an action.

We also know we're going to want the user to be able to search by zip code, so let's add that as well. Let's revisit the interface.

1 2 3 4 5 6 7 8 9 10 interface WeatherStore { /** Weather of the browser's current location, if available */ readonly currentLocationWeather?: Weather; /** Updates the currentLocationWeather */ getWeatherForLocation(longitude: number, latitude: number): Promise<void>; /** Returns the weather for a zip code. Does not store in the app. */ getWeatherForZip(zipCode: string): Promise<Weather>; }

Notice this time we're returning the Weather object instead of updating an observable property. This is because we don't need to hold the response in a global state, we plan on just showing it within the searched zip code view. The information will only be needed on the individual page or component that is using it.

Now let's implement it in the store.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 class WeatherStore implements WeatherStorable { @observable currentLocationWeather?: Weather; private readonly dataTransformer: OpenWeatherDataTransformable; constructor(dataTransformer: OpenWeatherDataTransformable) { makeObservable(this); this.dataTransformer = dataTransformer; } getWeatherForZip(zipCode: string): Promise<Weather> { return this.dataTransformer.getWeatherForZip(zipCode); } getWeatherForLocation(longitude: number, latitude: number): Promise<void> { return this.dataTransformer.getWeatherForCoordinates(longitude, latitude).then((weather) => { this.updateCurrentWeatherLocation(weather); return Promise.resolve(); }); } @action private updateCurrentWeatherLocation(weather?: Weather): void { this.currentLocationWeather = weather; } }

Testing

Tests on our store should be pretty simple so far! getWeatherForZip is essentially just a pass through of the data transformer's response, so we'll want to verify that's happening, and then verify getWeatherForLocation updates currentLocationWeather appropriately. It should be pretty easy once we've set up a MockOpenWeatherDataTransformer, and if you're following along, feel free to give it a shot.

Exactly similar to the data transformer test class setup, the base of every test file class looks something like this. I've added some starter sections, each in a describe

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 describe("WeatherStore tests", () => { let weatherStore: WeatherStore; let mockDataTransformer: MockOpenWeatherDataTransformer; beforeEach(() => { mockDataTransformer = new MockOpenWeatherDataTransformer(); weatherStore = new WeatherStore(mockDataTransformer); }); describe("On init", () => { // TODO }); describe("Given we are getting the weather by zip (getWeatherForZip)", () => { // TODO }); describe("Given we are getting the weather by coordinates (getWeatherForLocation)", () => { // TODO }); });

Here's my MockOpenWeatherDataTransformer, which once again is pure happy path and we can override it for unhappy path mocking.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 class MockOpenWeatherDataTransformer implements OpenWeatherDataTransformable { getWeatherForCoordinates(longitude: number, latitude: number): Promise<Weather> { return Promise.resolve({ temperature: 21, city: "Detroit", }); } getWeatherForZip(zipCode: string): Promise<Weather> { return Promise.resolve({ temperature: 21, city: "Detroit", }); } } export default MockOpenWeatherDataTransformer;

The main thing I want to test is that the currentLocationWeather is undefined to on init, and that it gets updated when we call getWeatherForCoordinates. I'll also go ahead and lock in the functionality for getWeatherForZip even though it's not doing much It may seem trivial, but tests like that have saved me when I've set up mock responses in my store and forgot to delete it before committing!

This store may seem unnecessary, and for this and a lot of the examples in this app it probably is. My goal here is to teach what I do and why, in the simplest possible way, so definitely focus more on the structure and less so on the content.

Here's my final tests file

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 describe("WeatherStore tests", () => { let weatherStore: WeatherStore; let mockDataTransformer: MockOpenWeatherDataTransformer; beforeEach(() => { mockDataTransformer = new MockOpenWeatherDataTransformer(); weatherStore = new WeatherStore(mockDataTransformer); }); describe("On init", () => { it("should have an undefined currentLocationWeather", () => { expect(weatherStore.currentLocationWeather).toBeUndefined(); }); }); describe("Given we are getting the weather by zip (getWeatherForZip)", () => { describe("when it is successful", () => { it("should return the weather object", () => { expect(weatherStore.getWeatherForZip("55555")).resolves.toEqual(MockWeather); }); }); describe("when it is unsuccessful", () => { beforeEach(() => { mockDataTransformer.getWeatherForZip = jest.fn(() => Promise.reject(UnexpectedDataFormatError)); }); it("should return the error", () => { expect(weatherStore.getWeatherForZip("55555")).rejects.toEqual(UnexpectedDataFormatError); }); }); }); describe("Given we are getting the weather by coordinates (getWeatherForLocation)", () => { let response: unknown; describe("when it is successful", () => { beforeEach(async () => { response = await weatherStore.getWeatherForLocation(123, 456); }); it("should return the weather object", () => { expect(response).toBeUndefined(); }); it("should update the current location", () => { expect(weatherStore.currentLocationWeather).toEqual(MockWeather); }); }); describe("when it is unsuccessful", () => { let response: unknown; beforeEach(async () => { weatherStore.currentLocationWeather = MockWeather; mockDataTransformer.getWeatherForCoordinates = jest.fn(() => Promise.reject(UnexpectedDataFormatError)); await weatherStore.getWeatherForLocation(123, 456).catch((error) => (response = error)); }); it("should return the error", () => { expect(response).toEqual(UnexpectedDataFormatError); }); it("should clear the current weather", () => { expect(weatherStore.currentLocationWeather).toBeUndefined(); }); }); }); });

Once again we're at full test coverage of a class! Feels great man 😁

What's Next?

Our WeatherStore is complete! Now we can use it in our view models!