sleepy

TOPICS

IntroductionReact (with MobX)ArchitectureModelsNetworkingData TransformersStoresViews & View Models

Views and View Models (MVVM!)

Responsibility

Displays app data and handles user interactions. The View is purely UI, the View Model is purely business logic.

Description

Now that our "model" of MVVM is set up (through the stores, data transformers, and networker), we can move on to the "view/view model" aspect of MVVM!

As mentioned in an introduction, the view model pattern is essentially taking what you've probably written previously in a single class and dividing it into separate pieces where one is purely UI and the other is purely logic for the UI.

The format I always follow for file names are *View.tsx and *ViewModel.ts with the directory being *. So for example, if I have a component I want to call MyComponent, my directory would be myComponent with a React component called MyComponentView and a class named MyComponentViewModel.

Weather Example

Let's set up a component that can take the zip code and display the current weather and location name for that zip.

I've created a directory in my src called components and inside that a directory called zipCodeWeather. Inside are ZipCodeWeatherView.tsx and ZipCodeWeatherViewModel.ts. To start, this is what my ZipCodeWeatherViewModel looks like

1 2 3 class ZipCodeWeatherViewModel {} export default ZipCodeWeatherViewModel;

and my ZipCodeWeatherView starts as

1 2 3 4 5 6 7 8 9 import ZipCodeWeatherViewModel from "./ZipCodeWeatherViewModel"; interface Props { viewModel: ZipCodeWeatherViewModel; } const ZipCodeWeatherView = ({ viewModel }: Props) => <div>Zip Code Weather View</div>; export default ZipCodeWeatherView;

Next, we know we want to take a zip code from our user, so let's add an input to our view and an onChange to our view model to track when the zip code changes an save its new value.

1 2 3 4 5 6 7 8 9 class ZipCodeWeatherViewModel { private zipcode?: string; readonly onChangeZipCode = (value: string): void => { this.zipcode = value; }; } export default ZipCodeWeatherViewModel;

Great! Now when we type in a zip code, it'll get updated in our view model to match what has been typed.

Next we will need a button to click to actually start the search, so let's add the button to the view, and an onClick method in the view model model. I like to keep my text that drives the view in my view model as well (especially text that can be concantonated or modified), so I'll add that now too.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 class ZipCodeWeatherViewModel { readonly actionButtonText = "Search"; private zipcode?: string; readonly onChangeZipCode = (value: string): void => { this.zipcode = value; }; /** Gets the weather for the current zip code */ readonly onClickGetWeather = (): void => { // TODO }; } export default ZipCodeWeatherViewModel;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 import ZipCodeWeatherViewModel from "./ZipCodeWeatherViewModel"; interface Props { viewModel: ZipCodeWeatherViewModel; } const ZipCodeWeatherView = ({ viewModel }: Props) => ( <div> <input onChange={(e) => viewModel.onChangeZipCode(e.target.value)} /> <button onClick={viewModel.onClickGetWeather}> {viewModel.actionButtonText} </button> </div> ); export default ZipCodeWeatherView

Finally, we'll want a way to show what the temperature for that zip code is (or a basic error message) so let's add that now too.

In order to use this with MobX, because it'll get updated by our search button being clicked, we'll need to mark it as @observable and make sure the view is an observer and also drop in a constructor to make the object observable.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 class ZipCodeWeatherViewModel { @observable weatherInformation = "[Search a zip to see its weather]"; readonly actionButtonText = "Search"; private zipcode?: string; constructor() { makeObservable(this) } readonly onChangeZipCode = (value: string): void => { this.zipcode = value; }; /** Gets the weather for the current zip code */ readonly onClickGetWeather = (): void => { // TODO }; } export default ZipCodeWeatherViewModel;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 import ZipCodeWeatherViewModel from "./ZipCodeWeatherViewModel"; interface Props { viewModel: ZipCodeWeatherViewModel; } const ZipCodeWeatherView = ({ viewModel }: Props) => ( <> <div> <input onChange={(e) => viewModel.onChangeZipCode(e.target.value)} /> <button onClick={viewModel.onClickGetWeather}> {viewModel.actionButtonText} </button> </div> <div>{viewModel.weatherInformation}</div> </> ); export default observer(ZipCodeWeatherView);

Everything is set up in the view now to search and show the weather for a zip, except for actually doing it!

We previously created the WeatherStorable interface and a WeatherStore that implement it which does exactly that, so let's inject that into our view model now so that we can search by zip!

We'll also set up our onClickGetWeather to actually get the weather object and then update our status string to show the current temperature.

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 class ZipCodeWeatherViewModel { @observable weatherInformation = "[Search a zip to see its weather]"; readonly actionButtonText = "Search"; private zipcode?: string; private readonly weatherStore: WeatherStorable; constructor(weatherStore: WeatherStorable) { makeObservable(this); this.weatherStore = weatherStore; } readonly onChangeZipCode = (value: string): void => { this.zipcode = value; }; /** Gets the weather for the current zip code */ readonly onClickGetWeather = (): void => { this.weatherStore .getWeatherForZip(this.zipcode) .then((weather) => this.updateTemperature(weather)) .catch(() => this.updateTemperature(undefined)); }; /** Updates the temperatureLabel */ @action private updateTemperature = (weather?: Weather): void => { if (!weather) { this.weatherInformation = "Error getting temperature"; return; } const fahrenheit = convertCelsiusToFahrenheit(weather.temperature); this.weatherInformation = `It is ${fahrenheit}°F / ${weather.temperature}°C in ${weather.city}.`; }; } export default ZipCodeWeatherViewModel;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 import ZipCodeWeatherViewModel from "./ZipCodeWeatherViewModel"; interface Props { viewModel: ZipCodeWeatherViewModel; } const ZipCodeWeatherView = ({ viewModel }: Props) => ( <> <div> <span>{viewModel.temperatureLabel}</span> <input onChange={(e) => viewModel.onChangeZipCode(e.target.value)} /> <button onClick={viewModel.onClickGetWeather}> {viewModel.actionButtonText} </button> </div> <div>{viewModel.weatherInformation}</div> </> ); export default observer(ZipCodeWeatherView);

Now we have our view which handles only UI related things and our view model which handles only the logic!

And now the whole reason why we build things this way, tests!!

Testing

Because we have our UI in one component and all of our logic in another, we can now easily write unit tests on the logic that drives our view.

Let's get started by creating a ZipCodeWeatherViewModel.test.ts file and our initial describe block.

1 2 3 4 5 // ZipCodeWeatherViewModel.test.ts describe("ZipCodeWeatherViewModel tests", () => { // TODO })

When writing tests on our view models, we want to think about what the happy paths are as well as sad paths, especially when they can be driven by something outside of the view model (in our case, something conforming to WeatherStorable). Let's identify some cases we might want to write tests for (and maybe other ways our app might perform that we haven't coded for yet!):

  1. Are there any initial values that could get changed later we might want to lock in? (Super useful to catch if we've accidentally left any debug code in when revisiting this view model at a later time)
  2. Clicking search, getting a successful response from the weather store, what does the weatherInformation set to?
  3. Clicking search, getting an error from the weather store, what does the weatherInformation set to?
  4. What happens if the zip code is less than 5 numbers? What if it's more? We know we need a 5 digit string, so what can we do to preventatively error handle?

It's often easiest to start with happy paths, so let's do that. When a successful response comes back from our weather store, let's make sure our weatherInformation is set to what we expect it to be.

In order to easily modify what our weather store returns, let's make a mock that follows the same protocol with each method doing the simplest default happy path behavior.

1 2 3 4 5 6 7 8 9 10 11 12 13 class MockWeatherStore implements WeatherStorable { currentLocationWeather?: Weather; getWeatherForLocation(longitude: number, latitude: number): Promise<void> { return Promise.resolve(undefined); } getWeatherForZip(zipCode: string): Promise<Weather> { return Promise.resolve(MockWeather); } } export default MockWeatherStore;

Now we can set up our beforeEach method which will reset our view model to the same state prior to every test.

1 2 3 4 5 6 7 8 9 10 11 // ZipCodeWeatherViewModel.test.ts describe("ZipCodeWeatherViewModel tests", () => { let viewModel: ZipCodeWeatherViewModel; let mockWeatherStore: MockWeatherStore; beforeEach(() => { mockWeatherStore = new MockWeatherStore(); viewModel = new ZipCodeWeatherViewModel(mockWeatherStore); }); })

First we know our weatherInformation property has some default value, so let's write a test to make sure it's as we expect on init. This is kind of a silly test and I understand why people don't write them, however tests like this have definitely saved me when writing new functionality or changing functionality to debug something and making sure it doesn't end up in production :)

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 // ZipCodeWeatherViewModel.test.ts describe("ZipCodeWeatherViewModel tests", () => { let viewModel: ZipCodeWeatherViewModel; let mockWeatherStore: MockWeatherStore; beforeEach(() => { mockWeatherStore = new MockWeatherStore(); viewModel = new ZipCodeWeatherViewModel(mockWeatherStore); }); describe("On init", () => { it("should have the default weather information string", () => { expect(viewModel.weatherInformation).toEqual("[Search a zip to see its weather]") }) }); })

This should pass and save our future self from introducing a typo or committing some debug code.

Next up, let's verify our weatherInformation string gets set to what we expect when the WeatherStorable object returns a successful response when we search by zip. Because our MockWeatherStore returns MockWeather by default, we'll lean on that to reduce our set up code.

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 // ZipCodeWeatherViewModel.test.ts describe("ZipCodeWeatherViewModel tests", () => { let viewModel: ZipCodeWeatherViewModel; let mockWeatherStore: MockWeatherStore; beforeEach(() => { mockWeatherStore = new MockWeatherStore(); viewModel = new ZipCodeWeatherViewModel(mockWeatherStore); }); describe("On init", () => { it("should have the default weather information string", () => { expect(viewModel.weatherInformation).toEqual("[Search a zip to see its weather]") }) }); describe("Given a valid zip code is entered", () => { beforeEach(() => { // We can easily mimic what the UI does by calling the onChange method in the view model. // This is exactly what our React component will be doing when the user enters the full zip code viewModel.onChangeZipCode("48226"); }); describe("Given onClickGetWeather is clicked", () => { describe("when the response is successful", () => { beforeEach(() => { viewModel.onClickGetWeather(); }); it("should have a new temperatureLabel", () => { expect(viewModel.weatherInformation).toEqual("It is 70°F / 21°C in Detroit."); }); }); }); }); })

We are testing that when there is a successful response that includes a Kelvin temperature of 21 degrees, that our weatherInformation not only converts this temperature to Celsius and Fahrenheit, but that it also concatenates the string in the correct way. This functionality is not only verified to be working, but is now locked in so that future modifications will throw an alert if we accidentally break something.

Now for the good stuff! What if the response from the WeatherStorable object is an error? How will our view perform? Let's verify it!

In a production app, we'd probably have more complicated error handling and perhaps our weatherInformation text would give us more context into the error, so this is more of an example to show easy it is to set up and test sad paths.

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 // ZipCodeWeatherViewModel.test.ts describe("ZipCodeWeatherViewModel tests", () => { let viewModel: ZipCodeWeatherViewModel; let mockWeatherStore: MockWeatherStore; /// ... set up and other tests, leaving out to make what's changing easier to read describe("Given a valid zip code is entered", () => { beforeEach(() => { // We can easily mimic what the UI does by calling the onChange method in the view model. // This is exactly what our React component will be doing when the user enters the full zip code viewModel.onChangeZipCode("48226"); }); describe("Given onClickGetWeather is clicked", () => { describe("when the response is successful", () => { beforeEach(() => { viewModel.onClickGetWeather(); }); it("should have a new temperatureLabel", () => { expect(viewModel.weatherInformation).toEqual("It is 70°F / 21°C in Detroit."); }); }); describe("when the response is unsuccessful", () => { beforeEach(() => { mockWeatherStore.getWeatherForZip = () => Promise.reject({message: "Mock error"}); viewModel.onClickGetWeather(); }); it("should show the error message", () => { expect(viewModel.weatherInformation).toEqual("Error getting temperature"); }); }); }); }); })

Great! Now we know exactly what will happen if our WeatherStorable responds with an error.

One case we know will definitely cause an error from our weather service is if we try to search with an invalid zip code. We know we don't even need to go to the weather service for that, we can handle that before we even try to reach out for that call.

One way to prevent this is by verifying the zip code is 5 characters long before we even make a call to the service. Maybe we'll even add some helpful UX and disable the button until we meet that requirement so it's obvious to the user why nothing is happening when they click search.

Let's go back to our view model and add in the character length check.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 class ZipCodeWeatherViewModel { /// ... setup and properties, eliminating to save space and focus on what's changing /** Gets the weather for the current zip code */ readonly onClickGetWeather = (): void => { if (!this.zipcode || this.zipcode.length !== 5) return; this.weatherStore .getWeatherForZip(this.zipcode) .then((weather) => this.updateTemperature(weather)) .catch(() => this.updateTemperature(undefined)); }; } export default ZipCodeWeatherViewModel;

Now let's add a test to verify nothing happens if the zip code doesn't meet the character requirements:

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 // ZipCodeWeatherViewModel.test.ts describe("ZipCodeWeatherViewModel tests", () => { let viewModel: ZipCodeWeatherViewModel; let mockWeatherStore: MockWeatherStore; /// ... set up and other tests, leaving out to make what's changing easier to read describe("Given an invalid zip is entered", () => { beforeEach(() => { viewModel.onChangeZipCode("4822"); // One digit short of a valid zip }); describe("when on clickGetWeather is clicked", () => { beforeEach(() => { // This let's us easily "spy" on a function... mockWeatherStore.getWeatherForZip = jest.fn(); viewModel.onClickGetWeather(); }); it("should not attempt to get weather", () => { //... so we can verify the weather store is never even being called expect(mockWeatherStore.getWeatherForZip).not.toBeCalled(); }); }); }); })

Perfect! But what about our user's experience? If they don't notice they've only entered four characters, they'll be confused why nothing is happening. So let's disable the search button until that requirement is met (in a real app, we'd probably do something even better, but this is a nice start).

Now, we could create an observable property called disabled that gets manually updated in onChangeZipCode depending on the length of the zip code and this would work perfectly fine. BUT, because we already save the currently typed zip code in our view model, we can also accomplish this with a computed property that checks checks the length of the zip code! This reduces the possibility that the button being disabled will somehow get out of sync with the requirements of the zip's length.

First we'll need to mark our zipCode as @observable so that it'll trigger the @computed property to be pushed to anything observing it on change (this syntax is specific to MobX but is not uncommon in other reactive frameworks). Then we'll create a computed property called buttonDisabled that we can set on our button component in our view.

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 class ZipCodeWeatherViewModel { @observable weatherInformation = "[Search a zip to see its weather]"; readonly actionButtonText = "Search"; @observable private zipcode?: string; private readonly weatherStore: WeatherStorable; constructor(weatherStore: WeatherStorable) { makeObservable(this); this.weatherStore = weatherStore; } /** Based on the zipcode observable, whenever zipcode changes, this is recomputed */ @computed get buttonDisabled(): boolean { return this.zipcode?.length !== 5; } readonly onChangeZipCode = (value: string): void => { this.zipcode = value; }; /** Gets the weather for the current zip code */ readonly onClickGetWeather = (): void => { this.weatherStore .getWeatherForZip(this.zipcode) .then((weather) => this.updateTemperature(weather)) .catch(() => this.updateTemperature(undefined)); }; /** Updates the temperatureLabel */ @action private updateTemperature = (weather?: Weather): void => { if (!weather) { this.weatherInformation = "Error getting temperature"; return; } const fahrenheit = convertCelsiusToFahrenheit(weather.temperature); this.weatherInformation = `It is ${fahrenheit}°F / ${weather.temperature}°C in ${weather.city}.`; }; } export default ZipCodeWeatherViewModel;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 import ZipCodeWeatherViewModel from "./ZipCodeWeatherViewModel"; interface Props { viewModel: ZipCodeWeatherViewModel; } const ZipCodeWeatherView = ({ viewModel }: Props) => ( <> <div> <span>{viewModel.temperatureLabel}</span> <input onChange={(e) => viewModel.onChangeZipCode(e.target.value)} /> <button disabled={viewModel.buttonDisabled} onClick={viewModel.onClickGetWeather}> {viewModel.actionButtonText} </button> </div> <div>{viewModel.weatherInformation}</div> </> ); export default observer(ZipCodeWeatherView);

Perfect! Now let's verify it works with some tests.

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 // ZipCodeWeatherViewModel.test.ts describe("ZipCodeWeatherViewModel tests", () => { let viewModel: ZipCodeWeatherViewModel; let mockWeatherStore: MockWeatherStore; describe("On init", () => { /// ...other tests // NEW: Verify the button is disabled on init because there is no zip entered it("should disable the weatherButton since no zip is entered", () => { expect(viewModel.buttonDisabled).toBeTruthy(); }); }); /// ... set up and other tests, leaving out to make what's changing easier to read describe("Given a valid zip code is entered", () => { beforeEach(() => { viewModel.onChangeZipCode("48226"); }); // NEW: Verify the button is enabled when a valid zip is entered it("should enable the weatherButton", () => { expect(viewModel.buttonDisabled).toBeFalsy(); }); /// ...other tests }); describe("Given an invalid zip is entered", () => { beforeEach(() => { viewModel.onChangeZipCode("4822"); // One digit short of a valid zip }); // NEW: Verify the button is enabled when a valid zip is entered it("should not enable the getWeather button", () => { expect(viewModel.buttonDisabled).toBeTruthy(); }); describe("when on clickGetWeather is clicked", () => { beforeEach(() => { // This let's us easily "spy" on a function... mockWeatherStore.getWeatherForZip = jest.fn(); viewModel.onClickGetWeather(); }); it("should not attempt to get weather", () => { //... so we can verify the weather store is never even being called expect(mockWeatherStore.getWeatherForZip).not.toBeCalled(); }); }); }); })

Now you'll notice that if our button is disabled, there shouldn't be a way to click it at all, and it basically renders the rest of our tests in the "Given an invalid zip is entered" block kind of moot. However, because we already wrote them, and because the disabled button functionality may change one day (perhaps we let the user click but show a helper message indicating they need 5 characters instead), I'm going to leave them. We would never want to call the WeatherStorable object without a valid zip code anyway, and this will protect us in the event of other functionality change. I'm a big fan of "coding defensively" and this is an example of doing that by not depending on the button being disabled.

And that's it! The basics of view model testing and how easy it is to verify your view's logic without getting bogged down by UI. Are there any other tests you'd add here? What about if we built a view that used the user's current location? What tests would you write? What do you need to mock? What are the sad and happy paths for your view model (and what are the sad and happy paths your dependencies could return?)

In the mvvm-web repo on my GitHub, I have another view that gets the user's current location and retrieves the weather for that but I encourage you to give it a shot yourself and peak at my code if you're stuck.

Thanks for reading and happy testing!