Design Patterns For Vuejs A Test Driven Approach To Maintainable Applications
Design Patterns For Vuejs A Test Driven Approach To Maintainable Applications
4 Emitting Events 21
4.1 Starting Simple . . . . . . . . . . . . . . . . . . . . . . . . . . . . 21
4.2 Clean Templates . . . . . . . . . . . . . . . . . . . . . . . . . . . 23
4.3 Declaring emits . . . . . . . . . . . . . . . . . . . . . . . . . . . . 24
4.4 More Robust Event Validation . . . . . . . . . . . . . . . . . . . 25
4.5 With the Composition API . . . . . . . . . . . . . . . . . . . . . 27
4.6 Conclusion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 28
1
6.7 Conclusion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 61
6.8 Exercises . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 62
7 Renderless Components 63
7.1 Rendering without Markup . . . . . . . . . . . . . . . . . . . . . 64
7.2 Adding Password and Confirmation Inputs . . . . . . . . . . . . 66
7.3 Adding Password Complexity . . . . . . . . . . . . . . . . . . . . 69
7.4 Computing Form Validity . . . . . . . . . . . . . . . . . . . . . . 74
7.5 Exercises . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 78
2
11.5 Making a Move . . . . . . . . . . . . . . . . . . . . . . . . . . . . 143
11.6 Conclusion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 148
11.7 Exercises . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 148
3
1 About the Book
This book is aimed at developers who are comfortable with JavaScript and
Vue.js. It focuses on ideas and patterns rather than specific APIs. Separation of
concerns, pure functions, writing for testability and re-usability are some of the
primary motifs.
The examples are written with Vue.js 3, the latest version of Vue.js. Both the
classic Options API and new Composition API are covered. Tests are written
with Vue Testing Library: https://github.com/testing-library/vue-testing-library.
If you prefer Vue Test Utils, no problem - the source code contains the same
tests written using both Testing Library and Vue Test Utils.
The final source code including the solutions to the exercises is available here:
https://github.com/lmiller1990/design-patterns-for-vuejs-source-code
4
2 Design Patterns for Vue.js - a Test Driven Ap-
proach to Maintainable Applications
2.1 Introduction
This is a book about design patterns and testing. But it’s also more. Thinking
in design patterns is not about memorizing a lot of fancy names and diagrams.
Knowing how to test is not really about learning a test runner or reading
documentation.
Thinking in patterns, consider how data flows between different parts of a system
and writing for testability starts before writing any code.
5
Good software design is a philosophy. It’s a way of life. Finally, as engineer,
writing good software is your job. So is writing testable code - even if HR forgot
to put it in your job description.
My goal is to get you in the habit of writing testable code, and how to choose
the right abstraction for the problem at hand. The first things you think when
you hear a new business requirement or request should be:
- What design pattern will give me the most flexibility moving forward?
- What new requirements could come up, and how will this decision deal with
them?
- How am I going to write my code in a testable, loosely coupled fashion?
The lessons and patterns I’ll be sharing are not Vue-specific at all; they are
framework agnostic. I’d even say that they are language agnostic; they are
fundamental concepts you can take with you and apply them to any software
design problem. Good developers focus on tools and frameworks, great developers
focus on data structures and how they interact with each other, testability and
maintainability.
All of the content is, of course, based on my opinion. Like most best practices
and design patterns, there is a time and place for everything, and not every
recommendation will apply to every use case. The best way to get value from
this book is to read the examples, think about the concepts and compare it to
what you are currently doing.
If you think it solves a problem you have, try it out and see where it leads.
There will always be exceptions, but the most important thing is you think
about your concerns, testability and reliability. If you disagree with something
you see, I’m always open to discussion and improvements; please reach out. I’m
always interested in new perspectives and ideas.
Most books that teach you frameworks, languages or testing will be an app or
two, incrementally adding new features. This works great for learning a new
language or framework, but isn’t ideal for focusing on concepts or ideas. In this
book, each section will be focused on a single idea, and we will build a small
component or application to illustrate it. This approach has a few benefits; you
can read the content is any order, and use it as a kind of reference.
We start with some patterns for props, as well as a discussion around one of
the most fundamental ideas in this book, separation of concerns. We proceed to
cover a wide variety of design patterns for events, forms, components, renderless
components, feature separation with the Composition API, and everything else
you’ll need to know to create well engineered Vue.js applications.
6
Most sections end with some exercises to help you improve and practice what
you learned. The source code, including all the solutions for the exercises are
included in the source code: (https://github.com/lmiller1990/design-patterns-
for-vuejs-source-code), so you can check your solutions.
Each section is independent; you don’t need to read it in order, so if there is a
particular section you are interested in, feel free to skip to it. Try to think of
this book as a reference tool; I hope it is something you can come back to for
years to come and learn something useful each time.
I hope this has given you a good idea of what to expect. If you have any feedback,
questions ors comments, or just want to chat about Vue and testing, feel free to
reach out via email or Twitter (find my most up to date contact details on the
website you got this book).
See you in the next section!
7
3 Patterns for Testing Props
You can find the completed source code in the GitHub repository under exam-
ples/props:
https://github.com/lmiller1990/design-patterns-for-vuejs-source-code.
In this section we explore props, and the kind of tests you might want to consider
writing. This leads into a much more fundamental and important topic; drawing
a clear line between business logic and UI, also known as separation of concerns,
and how your tests can help make this distinction clear.
Consider one of the big ideas behind frameworks like Vue and React:
Your user interface is a function of your data.
This idea comes in many forms; another is “data driven interfaces”. Basically,
your user interface (UI) should be determined by the data present. Given X data,
your UI should be Y. In computer science, this is referred to as determinism.
Take this sum function for example:
function sum(a, b) {
return a + b
}
A impure function - it has a side effect. Not ideal, but necessary for most
systems to do anything useful.
This is not a pure function because it relies on an external resource - in this case
an API and a database. Depending on what is in the database when it is called,
we might get a different result. It’s unpredictable.
How does this relate to props? Think of a component that decides what to
render based on it’s props (don’t worry about data, computed or setup for now
- but the same ideas apply, as you’ll see throughout the book). If you think
of a component as a function and the props as the arguments, you’ll realize
that given the same props, the component will always render the same thing.
It’s output is deterministic. Since you decide what props are passed to the
component, it’s easy to test, since we know all the possible states the component
can be in.
8
3.1 The Basics
You can declare props in a few ways. We will work with the <message> component
for this example. You can find it under examples/props/message.vue.
<template>
<div :class="variant">Message</div>
</template>
<script>
export default {
// can be 'success', 'warning', 'error'
props: ['variant']
}
</script>
export default {
props: {
variant: {
type: String as () => Variant,
required: true
}
}
}
9
specify specific strings for the variant props like you can in TypeScript. There
are some other patterns we can use, though.
We have specified the variant prop is required, and we would like to enforce
a specific subset of string values that it can receive. Vue allows us to validate
props using a validator key. It works like this:
export default {
props: {
variant: {
validator: (val) => {
// if we return true, the prop is valid.
// if we return false, a runtime warning will be shown.
}
}
}
}
Prop validators are functions. If they return false, Vue will show a warning in
the console.
Prop validators are like the sum function we talked about earlier in that they are
pure functions! That means they are easy to test - given X prop, the validator
should return Y result.
Before we add a validator, let’s write a simple test for the <message> component.
We want to test inputs and outputs. In the case of <message>, the variant prop
is the input, and what is rendered is the output. We can write a test to assert
the correct class is applied using Testing Library and the classList attribute:
import { render, screen } from '@testing-library/vue'
import Message, { validateVariant } from './message.vue'
describe('Message', () => {
it('renders variant correctly when passed', () => {
const { container } = render(Message, {
props: {
variant: 'success'
}
})
expect(container.firstChild.classList).toContain('success')
})
})
10
prohibit using the <message> component with a valid variant. This is a good
use case for a validator.
return true
}
}
}
}
11
<script>
export function validateVariant(variant) {
if (!['success', 'warning', 'error'].includes(variant)) {
throw Error(
`variant is required and must` +
`be either 'success', 'warning' or 'error'.` +
`You passed: ${variant}`
)
}
return true
}
export default {
props: {
variant: {
type: String,
required: true,
validator: validateVariant
}
}
}
</script>
describe('Message', () => {
it('renders variant correctly when passed', () => {
// omitted for brevity ...
})
12
Testing all the cases for the validator.
Simply making the validateVariant a separate function that is exported might
seem like a small change, but it’s actually a big improvement. By doing so, we
were able to write tests for validateVariant with ease. We can be confident
the <message> component can only be used with valid a variant.
If the developer passes an invalid prop, they get a nice clear message in the
console:
We have written two different types of tests. The first is a UI test - that’s the one
where we make an assertions against classList. The second is for the validator.
It tests business logic.
To make this more clear, imagine your company specializes in design systems.
You have some designers who probably use Figma or Sketch to design things
like buttons and messages.
13
They have decided to support for three message variants: success, warning and
error. You are a front-end developer. In this example, you are working on the
Vue integration - you will write Vue components that apply specific classes,
which use the CSS you provided by the designers.
In the future, you also need to build React and Angular components using the
same CSS and guidelines. All three of the integrations could make use of the
validateVariant function and test. It’s the core business logic.
This distinction is important. When we use Testing Library methods (such as
render) and DOM APIs (like classList) we are verifying that the Vue UI layer
is working correctly. The test for validateVariant is for our business logic.
These differences are sometimes called concerns. One piece of code is concerned
with the UI. The other is concerned with the business logic.
Separating them is good. It makes your code easier to test and maintain. This
concept is known as separation of concerns. We will revisit this throughout the
book.
If you want to know if something is part of the UI or business logic, ask yourself
this: “if I switched to React, would I be able to re-use this code and test?”.
In this case, you could reuse the validator and it’s test when you write the React
integration. The validator is concerned with the business logic, and doesn’t know
anything about the UI framework. Vue or React, we will only support three
message variants: success, warning and error. The component and component
test (where we assert using classes()) would have to be rewritten using a React
component and React testing library.
Ideally, you don’t want your business logic to be coupled to your framework of
choice; frameworks come and go, but it’s unlikely the problems your business is
solving will change significantly.
I have seen poor separation of concerns costs companies tens of thousands of
dollars; they get to a point where adding new features is risky and slow, because
their core business problem is too tightly coupled to the UI. Rewriting the UI
means rewriting the business logic.
14
with the UI logic (this is the bad part). They had a quantity based discount
model - “If purchasing more than 50 resistors, then apply X discount, otherwise
Y” - this kind of thing. They decided to move to something a bit more modern -
the UI was very dated, and wasn’t mobile friendly at all. The complexity of the
jQuery code was high and the code was a mess.
Not only did I need to rewrite the entire UI layer (which was what I was paid
to do), but I ended up having to either rewrite or extract the vast majority of
the business logic from within the jQuery code, too. This search and extract
mission made the task much more difficult and risky than it should have been -
instead of just updating the UI layer, I had to dive in and learn their business
and pricing model as well (which ended up taking a lot more time and costing a
lot more than it probably should have).
Here is a concrete example using the above real-world scenario. Let’s say a
resistor (a kind of electrical component) costs $0.60. If you buy over 50, you get
a 20% discount. The jQuery code-base looked something like this:
const $resistorCount = $('#resistors-count')
$resistorCount.change((event) => {
const amount = parseInt (event.target.value)
const totalCost = 0.6 * amount
const $price = $("#price")
if (amount > 50) {
$price.value(totalCost * 0.8)
} else {
$price.value(totalCost)
}
})
You need to look really carefully to figure out where the UI ends and the business
starts. In this scenario, I wanted to move to Vue - the perfect tool for a highly
dynamic, reactive form. I had to dig through the code base and figure out
this core piece of business logic, extract it, and rewrite it with some tests (of
course the previous code base had no tests, like many code bases from the early
2000s). This search-extract-isolate-rewrite journey is full of risk and the chance
of making a mistake or missing something is very high! What would have been
much better is if the business logic and UI had be separated:
const resistorPrice = 0.6
function resistorCost(price, amount) {
if (amount > 50) {
return price * amount * 0.8
} else {
return price * amount
}
}
15
$resistorCount.change((event) => {
const amount = parseInt (event.target.value)
$("#price").value = resistorCost(resistorPrice, amount)
})
The second is far superior. You can see where the business logic ends and the
UI begins - they are literally separated in two different functions. The pricing
strategy is clear - a discount for any amount greater than 50. It’s also very
easy to test the business logic in isolation. If the day comes you decide your
framework of choice is no longer appropriate, it’s trivial to move to another
framework - your business logic unit tests can remain unchanged and untouched,
and hopefully you have some end-to-end browser tests as well to keep you safe.
Moving to Vue is trivial - no need to touch the business logic, either:
<template>
<input v-model="amount" />
<div>Price: {{ totalCost }}</div>
</template>
<script>
import { resistorCost, resistorPrice } from './logic.js'
export default {
data() {
return {
amount: 0
}
},
computed: {
totalCost() {
return resistorCost(resistorPrice, this.amount)
}
}
}
</script>
Enough design philosophy for now. Let’s see another example related to
props. This examples uses the <navbar> component. You can find it in
examples/props/navbar.vue. It looks like this:
16
<template>
<button v-if="authenticated">Logout</button>
<button v-if="!authenticated">Login</button>
</template>
<script>
export default {
props: {
authenticated: {
type: Boolean,
default: false
}
}
}
</script>
describe('navbar', () => {
it('shows logout when authenticated is true', () => {
render(Navbar, {
props: {
authenticated: true
}
})
screen.getByText('Login')
})
})
17
The only thing that changes based on the value of authenticated is the button
text. Since the default value is false, we don’t need to pass it as in props in
the second test.
We can refactor a little with a renderNavbar function:
describe('Navbar', () => {
function renderNavbar(props) {
render(Navbar, {
props
})
}
18
it('shows logout by default', () => {
// ...
})
We can do a little sanity check and make sure our tests are not testing imple-
mentation details. Implementation details refers to how something works. When
testing, we don’t care about the specifics of how something works. Instead, we
care about what it does, and if it does it correctly. Remember, we should be
testing that we get the expected output based on given inputs. In this case, we
want to test that the correct text is rendered based on the data, and not caring
too much about how the logic is actually implemented.
We can validate this by refactoring the <navbar> component. As long as the
tests continue to past, we can be confident they are resilient to refactors and are
testing behaviors, not implementation details.
Let’s refactor <navbar>:
<template>
<button>
{{ `${authenticated ? 'Logout' : 'Login'}` }}
</button>
</template>
<script>
export default {
props: {
19
authenticated: {
type: Boolean,
default: false
}
}
}
</script>
<script>
export default {
props: {
authenticated: {
type: Boolean,
default: false
}
}
}
</script>
3.7 Conclusion
This chapter discussed some techniques for testing props. We also saw how to
use Testing Library’s render method to test components. We touched on the
concept of separation of concerns, and how it can make your business logic more
testable and your applications more maintainable. Finally, we saw how tests can
let us refactoring code with confidence.
You can find the completed source code in the GitHub repository under exam-
ples/props:
https://github.com/lmiller1990/design-patterns-for-vuejs-source-code.
20
4 Emitting Events
You can find the completed source code in the GitHub repository under exam-
ples/events:
https://github.com/lmiller1990/design-patterns-for-vuejs-source-code.
<script>
export default {
data() {
return {
count: 0
}
}
}
</script>
21
let us know if we break something.
import { render, screen, fireEvent } from '@testing-library/vue'
import Counter from './counter.vue'
describe('Counter', () => {
it('emits an event with the current count', async () => {
const { emitted } = render(Counter)
await fireEvent.click(screen.getByRole('increment'))
await fireEvent.click(screen.getByRole('submit'))
console.log(emitted())
})
})
22
import { render, screen, fireEvent } from '@testing-library/vue'
import Counter from './counter.vue'
describe('Counter', () => {
it('emits an event with the current count', async () => {
const { emitted } = render(Counter)
await fireEvent.click(screen.getByRole('increment'))
await fireEvent.click(screen.getByRole('submit'))
expect(emitted().submit[0]).toEqual([1])
})
})
Templates can often get chaotic among passing props, listening for events and
using directives. For this reason, wherever possible, we want to keep our templates
simple by moving logic into the <script> tag. One way we can do this is to avoid
writing count += 1 and $emit() in <template>. Let’s make this change in the
<counter> component, moving the logic from <template> into the <script>
tag by creating two new methods:
<template>
<button role="increment" @click="increment" />
<button role="submit" @click="submit" />
</template>
<script>
export default {
data() {
return {
count: 0
}
},
methods: {
submit() {
this.$emit('submit', this.count)
},
increment() {
this.count += 1
}
}
23
}
</script>
As of Vue 3, you are able to (and encouraged to) declare the events your
component will emit, much like you declare props. It’s a good way to communicate
to the reader what the component does. Also, if you are using TypeScript, you
will get better autocompletion and type safety.
Failing to do so will give you a warning in the browser console: “Component
emitted event”" but it is neither declared in the emits option nor as an “ prop”.
By declaring the events a component emits, it can make it easier for other
developers (or yourself in six months time) to understand what your component
does and how to use it.
You can declare events in the same way you declare props; using the array
syntax:
24
export default {
emits: ['submit']
}
Depending on your application, you may want to have more thorough validation.
I tend to favor defensive programming; I don’t like taking chances, not matter
how unlikely the scenario might seem.
Getting burned by a lack of defensive programming and making assumptions like
“this will never happen in production” is something everyone has experienced.
It’s almost a rite of passage. There is a reason more experienced developers tend
to be more cautious, write defensive code, and write lots of tests.
I also have a strong emphasis on testing, separation of concerns, and keeping
things simple and modular. With these philosophies in mind, let’s extract this
validator, make it more robust, and add some tests.
25
The first step is to move the validation out of the component definition. For
brevity, I am just going to export it from the component file, but you could
move it to another module entirely (for example, a validators module).
<script>
export function submitValidator(count) {
return typeof count !== 'string' && !isNaN (count)
}
export default {
emits: {
submit: submitValidator
},
data() {
return {
count: 0
}
},
methods: {
increment() {
this.count += 1
}
}
}
</script>
26
describe('submitValidator', () => {
it('throws and error when count isNaN', () => {
const actual = () => submitValidator('1')
expect(actual).toThrow()
})
The <counter> example used the Options API. All the topics discussed here
translate to the Composition API, too.
A good way to see if you are testing inputs and outputs, as opposed to imple-
mentation details, is to refactor your component from the Options API to the
Composition API, or vice versa; good tests are resilient to refactor.
Let’s see the refactor:
<template>
<button role="increment" @click="increment" />
<button role="submit" @click="submit" />
</template>
<script>
export function submitValidator(count) {
if (typeof count === 'string' || isNaN (count)) {
throw Error(`
Count should be a number.
Got: ${count}
`)
}
return true
}
27
export default {
emits: {
submit: submitValidator
},
setup(props, { emit }) {
const count = ref(0)
return {
count,
increment,
submit
}
}
}
</script>
4.6 Conclusion
We discussed emitting events, and the various features Vue provides to keep our
components clean and testable. We also covered some of my favorite conventions
and best practices to keep things maintainable in the long run, as well as bring
consistency to your code base.
Finally, we saw how our tests was focused on inputs and outputs (in this case,
the input is the user interation via the buttons, and the output is the emitted
submit event).
We touch on events again later on, in the v-model chapter - stay tuned.
You can find the completed source code in the GitHub repository under exam-
ples/events:
https://github.com/lmiller1990/design-patterns-for-vuejs-source-code.
28
5 Writing Testable Forms
You can find the completed source code (including exercises) in the GitHub
repository under examples/form-validation:
https://github.com/lmiller1990/design-patterns-for-vuejs-source-code.
Forms are the primary way a user enters information into any web-based system,
so getting them right is important. The focus of this section will be on forms,
specifically writing good forms.
What exactly is a good form?
We want to ensure the form logic is decoupled from the Vue components - this
will let us test in isolation. We also need to think about validation.
In traditional server-rendered apps, you would only get validation after submitting
the form - not a great user experience. Vue allows us to deliver a great user
experience by implementing highly dynamic, client-side validation. We will make
use of this and implement two levels of validation:
1. Field validation - if a user enters incorrect in invalid data in a single field,
we will show an error immediately.
2. Form validation - the submit button should only be enabled when the
entire form is correctly filled out.
Finally, we need two types of tests. The first is around the business logic; given
some form, which fields are invalid, and when is the form considered complete?
The second is around interactions - ensuring that the UI layer is working correctly
and that the user can enter data, see error messages, and submit the form if all
the fields are valid.
29
5.1 The Patient Form
For this example, we are building a form to enter patient data for a hospital
application. The form will look like this when filled out without any errors:
30
There are two inputs. The first is the patient name, which is required and can be
any text. The second is the patient weight, which can be in imperial or metric
units. The constraints are as follows:
We will need to validate both the name and the weight. The form with errors
looks like this:
31
Figure 3: Invalid form with debug info
32
The submit button should only be enabled if both inputs are valid. Finally, we
should show validation for each field.
There are plenty of full-featured Vue (and non-Vue) form validation frameworks
out there. For this simple example, we will write our own - this will let us discuss
some ideas, as well as avoid learning a specific API or library.
We need two types of validations:
1. A required field. Both the patient’s name and weight are required fields.
2. Minimum and maximum constraints. This is for the weight field - it has
to be within a specific range. It also needs to support metric and imperial
units.
As well as validating the fields, our form validation framework should also return
an error messages for each invalid input.
We will write two validation functions: required and isBetween. While test
driven development (abbreviated to TDD - where you write the tests first, and
let the failing tests guide the implementation) isn’t always the right tool, for
writing these two functions I believe it is. This is because we know the inputs
and outputs, and all the possible states of the system, it’s just a matter of writing
the tests and then making them pass.
Let’s do that - starting with the tests for the required validator. Each validator
will return an object with the validation status, and a message if there is an
error. A validated input should have this shape:
interface ValidationResult {
valid: boolean
message?: string
}
This will be the format our two validators (and any future ones) will need to
conform to. Now we’ve settled on our validation API, we can write the tests for
required.
33
5.3 The required validator
import {
required,
} from './form.js'
describe('required', () => {
it('is invalid when undefined', () => {
expect(required(undefined)).toEqual({
valid: false,
message: 'Required'
})
})
34
5.4 The isBetween validator
describe('required' () => {
// ...
})
describe('isBetween', () => {
it('returns true when value is equal to min', () => {
expect(isBetween(5, { min: 5, max: 10 }))
.toEqual({ valid: true })
})
35
.toEqual({
valid: false,
message: 'Must be between 5 and 10'
})
})
36
5.5 Building validateMeasurement with isBetween
Now we have written our little validation framework (well, two functions), it’s
time to validate the patient weight. We will build a validateMeasurement
function using isBetween and required.
Since we are supporting imperial and metric, we will be passing the constraints
as an argument. Dealing with which one is selected will be done later on, in the
UI layer.
There are three scenarios to consider:
1. The happy path when the value is valid.
2. The value is null/undefined.
3. The value is is defined, but outside the constraints.
I don’t feel the need to add tests for all the cases as we did with isBetween,
since we already tested that thoroughly.
import {
required,
isBetween,
validateMeasurement
} from './form.js'
describe('required' () => {
// ...
})
describe('isBetween', () => {
// ...
})
describe('validateMeasurement', () => {
it('returns invalid for input', () => {
const constraints = { min: 10, max: 30 }
const actual = validateMeasurement(undefined, { constraints })
expect(actual).toEqual({
valid: false,
message: 'Must be between 10 and 30'
37
})
})
})
38
5.6 The Form Object and Full Form Validation
We have completed all the validations for each field. Let’s think about the
structure of the form now.
We have two fields: name and weight.
1. name is a string.
2. weight is a number with associated units.
These are the inputs. It should have this shape:
// definition
interface PatientFormState {
name: string
weight: {
value: number
units: 'kg' | 'lb'
}
}
// usage
const patientForm: PatientFormState = {
name: 'John',
weight: {
value: 445,
units: 'lb'
}
}
interface PatientFormValidity {
name: ValidationResult
weight: ValidationResult
}
39
value: 445,
units: 'lb'
}
}
describe('required' () => {
// ...
})
describe('isBetween', () => {
// ...
})
describe('validateMeasurement', () => {
// ...
})
describe('isFormValid', () => {
40
it('returns true when name and weight field are valid', () => {
const form = {
name: { valid: true },
weight: { valid: true }
}
expect(isFormValid(form)).toBe(true)
})
expect(isFormValid(form)).toBe(false)
})
})
Testing isFormValid.
The implementation is simple:
export function isFormValid(form) {
return form.name.valid && form.weight.valid
}
isFormValid implementation.
You could get fancy and iterate over the form using Object.keys or
Object.entries if you were building a more generic form validation library.
This would be a more general solution. In this case, I am keeping it as simple as
possible.
The last test we need to complete the business logic is patientForm. This
function takes an object with the PatientFormState interface we defined earlier.
It returns the validation result of each field.
We will want to have quite a few tests here, to make sure we don’t miss anything.
The cases I can think of are:
1. Happy path: all inputs are valid
2. Patient name is null
3. Patient weight is outside constraints (imperial)
4. Patient weight is outside constraints (metric)
41
import {
required,
isBetween,
validateMeasurement,
isFormValid,
patientForm
} from './form.js'
describe('required' () => {
// ...
})
describe('isBetween', () => {
// ...
})
describe('validateMeasurement', () => {
// ...
})
describe('isFormValid', () => {
// ...
})
describe('patientForm', () => {
const validPatient = {
name: 'test patient',
weight: { value: 100, units: 'kg' }
}
42
value: 65,
units: 'lb'
}
})
expect(form.weight).toEqual({
valid: false,
message: 'Must be between 66 and 440'
})
})
expect(form.weight).toEqual({
valid: false,
message: 'Must be between 30 and 200'
})
})
})
Testing patientForm.
The test code is quite long! The implementation is trivial, however. In this
example, I am just hard-coding the weight constraints in an object called limits.
In a real-world system, you would likely get these from an API and pass them
down to the patientForm function.
const limits = {
kg: { min: 30, max: 200 },
lb: { min: 66, max: 440 },
}
43
return {
name,
weight
}
}
Implementing patientForm.
This completes the business logic for the patient form - noticed we haven’t
written and Vue components yet? That’s because we are adhering to one of our
goals; separation of concerns, and isolating the business logic entirely.
Now the fun part - writing the UI layer with Vue. Although I think TDD is a
great fit for business logic, I generally do not use TDD for my component tests.
I like to start by thinking about how I will manage the state of my component.
Let’s use the Composition API; I think works great for forms.
<script>
import { reactive, computed, ref } from 'vue'
import { patientForm, isFormValid } from './form.js'
export default {
setup() {
const form = reactive({
name: '',
weight: {
value: '',
units: 'kg'
}
})
return {
form,
validatedForm,
valid
}
}
}
</script>
44
Integrating the form business logic and the Vue UI layer.
I decided to keep the state in a reactive object. Both the valid state and
validateForm are computed values - we want the validation and form state to
update reactively when any value in the form changes.
Let’s add the <template> part now - it’s very simple, just good old HTML.
45
<template>
<h3>Patient Data</h3>
<form>
<div class="field">
<div v-if="!validatedForm.name.valid" class="error">
{{ validatedForm.name.message }}
</div>
<label for="name">Name</label>
<input id="name" name="name" v-model="form.name" />
</div>
<div class="field">
<div v-if="!validatedForm.weight.valid" class="error">
{{ validatedForm.weight.message }}
</div>
<label for="weight">Weight</label>
<input
id="weight"
name="weight"
v-model.number="form.weight.value"
/>
<select name="weightUnits" v-model="form.weight.units">
<option value="kg">kg</option>
<option value="lb">lb</option>
</select>
</div>
<div class="field">
<button :disabled="!valid">Submit</button>
</div>
</form>
<pre>
Patient Data
{{ form }}
</pre>
<br />
<pre>
Form State
{{ validatedForm }}
</pre>
</template>
46
Figure 4: Validation debug info
47
5.8 Some Basic UI Tests
We can add some basic UI tests using Testing Library, too. Here are two fairly
simple ones that cover most of the functionality:
import { render, screen, fireEvent } from '@testing-library/vue'
import FormValidation from './form-validation.vue'
describe('FormValidation', () => {
it('fills out form correctly', async () => {
render(FormValidation)
expect(screen.queryByRole('error')).toBe(null)
})
expect(screen.getAllByRole('error')).toHaveLength(2)
})
})
// Act - do things!
// Call functions
// Assign values
// Simulate interactions
await fireEvent.update(screen.getByLabelText('Name'), 'lachlan')
// Assert
expect(...).toEqual(...)
48
})
The goal here wasn’t to build the perfect form but illustrate how to separate
your form validation and business logic from the UI layer.
As it stands, you can enter any string into the weight field and it will be
considered valid - not ideal, but also trivial to fix. A good exercise would be
to write some tests to ensure the input is a number, and if not, return a useful
error message. We also haven’t got any tests to ensure the <button> is correctly
disabled.
5.10 Exercises
• Add a test to ensure that any non-numeric values entered into the weight
field cause the field to become invalid and show a “Weight must be a
number” error.
• Add a @submit.prevent listener to <form>. When the form is submitted,
emit an event with the patientForm.
• Submit the form using Testing Library and assert the correct event and
payload are emitted.
You can find the completed source code (including exercises) in the GitHub
repository under examples/form-validation:
https://github.com/lmiller1990/design-patterns-for-vuejs-source-code.
49
6 HTTP and API Requests
Something almost every Vue.js application is going to do is make HTTP requests
to an API of some sort. This could be for authentication, loading data, or
something else. Many patterns have emerged to manage HTTP requests, and
even more to test them.
This chapter looks at various ways to architecture your HTTP requests, different
ways to test them, and discusses the pros and cons of each approach.
The example I will use is the <login> component. It lets the user enter their
username and password and attempt to authenticate. We want to think about:
• where should the HTTP request be made from? The component, another
module, in a store (like Vuex?)
• how can we test each of these approaches?
There is no one size fits all solution here. I’ll share how I currently like to
structure things, but also provide my opinion on other architectures.
If you application is simple, you probably won’t need something like Vuex or an
isolated HTTP request service. You can just inline everything in your component:
<template>
<h1 v-if="user">
Hello, {{ user.name }}
</h1>
<form @submit.prevent="handleAuth">
<input v-model="formData.username" role="username" />
<input v-model="formData.password" role="password" />
<button>Click here to sign in</button>
</form>
<span v-if="error">{{ error }}</span>
</template>
<script>
import axios from 'axios'
export default {
data() {
return {
50
username: '',
password: '',
user: undefined,
error: ''
}
},
methods: {
async handleAuth() {
try {
const response = await axios.post('/login')
this.user = response.data
} catch (e) {
this.error = e.response.data.error
}
}
}
}
</script>
51
}))
describe('login', () => {
it('successfully authenticates', async () => {
render(App)
await fireEvent.update(
screen.getByRole('username'), 'Lachlan')
await fireEvent.update(
screen.getByRole('password'), 'secret-password')
await fireEvent.click(screen.getByText('Click here to sign in'))
expect(mockPost).toHaveBeenCalledWith('/login', {
username: 'Lachlan',
password: 'secret-password'
})
await screen.findByText('Hello, Lachlan')
})
})
If you are working on anything other than a trivial application, you probably
don’t want to store the response in component local state. The most common
way to scale a Vue app has traditionally been Vuex. More often than not, you
end up with a Vuex store that looks like this:
import axios from 'axios'
52
const response = await axios.post('/login', {
username,
password
})
commit('updateUser', response.data)
}
}
}
<script>
import axios from 'axios'
export default {
data() {
return {
username: '',
password: '',
error: ''
}
},
computed: {
user() {
return this.$store.state.user
}
},
methods: {
async handleAuth() {
try {
await this.$store.dispatch('login', {
username: this.username,
password: this.password
})
} catch (e) {
this.error = e.response.data.error
53
}
}
}
}
</script>
describe('login', () => {
it('successfully authenticates', async () => {
// add
render(App, { store })
})
})
<script>
import { reactive, ref, computed } from 'vue'
import { useStore } from 'vuex'
export default {
setup () {
54
const store = useStore()
const formData = reactive({
username: '',
password: '',
})
const error = ref('')
const user = computed(() => store.state.user)
return {
user,
formData,
error,
handleAuth
}
}
}
</script>
As your application gets larger and larger, though, using a real store can become
complex. Some developers opt to mock the entire store in this scenario. It leads
to less boilerplate, for sure, especially if you are using Vue Test Utils, which
55
has a mocks mounting option designed for mocking values on this, for example
this.$store.
Testing Library does not support mocking things so easily - intentionally. They
want your tests to be as production-like as possible, which means using real
dependencies whenever possible. I like this philosophy. To see why I prefer to
use a real Vuex store in my tests, let’s see what happens if we mock Vuex using
jest.mock.
let mockDispatch = jest.fn()
jest.mock('vuex', () => ({
useStore: () => ({
dispatch: mockDispatch,
state: {
user: { name: 'Lachlan' }
}
})
}))
describe('login', () => {
it('successfully authenticates', async () => {
render(App)
await fireEvent.update(
screen.getByRole('username'), 'Lachlan')
await fireEvent.update(
screen.getByRole('password'), 'secret-password')
await fireEvent.click(screen.getByText('Click here to sign in'))
expect(mockDispatch).toHaveBeenCalledWith('login', {
username: 'Lachlan',
password: 'secret-password'
})
await screen.findByText('Hello, Lachlan')
})
})
Mocking Vuex.
Since we are mocking the Vuex store now, we have bypassed axios entirely.
This style of test is tempting at first. There is less code to write. It’s very easy
to write. You can also directly set the state however you like - in the snippet
above, dispatch doesn’t actually update the state.
Again, the actual test code didn’t change much - we are no longer passing
a store to render (since we are not even using a real store in the test, we
mocked it out entirely). We don’t have mockPost any more - instead we have
mockDispatch. The assertion against mockDispatch became an assertion that
a login action was dispatched with the correct payload, as opposed to a HTTP
56
call to the correct endpoint.
There is a big problem. Even if you delete the login action from the store,
the test will continue to pass. This is scary! The tests are all green, which
should give you confidence everything is working correctly. In reality, your entire
application is completely broken.
This is not the case with the test using a real Vuex store - breaking the store
correctly breaks the tests. There is only one thing worse than a code-base with
no tests - a code-base with bad tests. At least if you have not tests, you have no
confidence, which generally means you spend more time testing by hand. Tests
that give false confidence are actually worse - they lure you into a false sense of
security. Everything seems okay, when really it is not.
The problem with the above example is we are mocking too far up the chain.
Good tests are as production like as possible. This is the best way to have
confidence in your test suite. This diagram shows the dependency chain in the
<login> component:
The previous test, where we mocked Vuex, mocks the dependency chain here:
57
This means if anything breaks in Vuex, the HTTP call, or the server, our test
will not fail.
The axios test is slightly better - it mocks one layer lower:
This is better. If something breaks in either the <login> or Vuex, the test will
fail.
Wouldn’t it be great to avoid mocking axios, too? This way, we could not need
to do:
let mockPost = jest.fn()
jest.mock('axios', () => ({
post: (url, data) => {
mockPost(url, data)
return Promise.resolve({
data: { name: 'Lachlan' }
})
}
}))
A new library has come into the scene relatively recently - Mock Service Worker,
or msw for short. This does exactly what is discussed above - it operates one
level lower than axios, mocking the actual network request! How msw works will
not be explained here, but you can learn more on the website: https://mswjs.io/.
One of the cool features is that you can use it both for tests in a Node.js
environment and in a browser for local development.
Let’s try it out. Basic usage is like this:
58
import { rest } from 'msw'
import { setupServer } from 'msw/node'
describe('login', () => {
beforeAll(() => server.listen())
afterAll(() => server.close())
59
await fireEvent.click(screen.getByText('Click here to sign in'))
60
it('handles incorrect credentials', async () => {
const error = 'Error: please check the details and try again'
server.use(
rest.post('/login', (req, res, ctx) => {
return res(
ctx.status(403),
ctx.json({ error })
)
})
)
render(App, { store })
await fireEvent.update(
screen.getByRole('username'), 'Lachlan')
await fireEvent.update(
screen.getByRole('password'), 'secret-password')
await fireEvent.click(screen.getByText('Click here to sign in'))
await screen.findByText(error)
})
})
6.7 Conclusion
This chapter introduces various strategies for testing HTTP requests in your
components. We saw the advantage of mocking axios and using a real Vuex
store, as opposed to mocking the Vuex store. We then moved one layer lower,
mocking the actual server with msw. This can be generalized - the lower the
mock in the dependency chain, the more confidence you can be in your test suite.
Tests msw is not enough - you still need to test your application against a real
server to verify everything is working as expected. Tests like the ones described
in this chapter are still very useful - they run fast and are very easy to write. I
tend to use testing-library and msw as a development tool - it’s definitely faster
than opening a browser and refreshing the page every time you make a change
to your code.
61
6.8 Exercises
• Trying using msw in a browser. You can use the same mock endpoint
handlers for both your tests and development.
• Explore msw more and see what other interesting features it offers.
62
7 Renderless Components
You can find the completed source code in the GitHub repository under
examples/renderless-password:
https://github.com/lmiller1990/design-patterns-for-vuejs-source-code.
The primary way you reuse components in Vue is slots. This works great for a
lot of cases, but sometimes you need more flexibility.
One example is you have some complex logic that needs to be reused in two
different interfaces. One way to reuse complex logic with several different
interfaces is the renderless component pattern.
In this section we will build the following component, a password strength form:
63
There are a few requirements. We’d like to publish this on npm; to make it as
flexible as possible, we will use a technique known as a “renderless” component.
This means we will not ship and specific markup. Instead, the developer will
need to provide their own.
This means we will work with a render function, the low-level JavaScript that
<template> is compiled to. This will allow developers to fully customize the
markup and style as they see fit.
We would like to support the following features:
• A matching variable that returns true if the password and confirmation
match.
• Support a minComplexity prop; by default, the minimum complexity is 0
and the maximum complexity is 3. This is represented by the yellow bar
above the submit button in the screenshot above.
• support a custom complexity algorithm (eg, require specific characters or
numbers in the password).
• Expose a valid value which is true when the password and confirmation
match and the password meets the minimum complexity.
Let’s get started.
64
>
{{ complexity }}
</renderless-password>
</template>
<script>
import RenderlessPassword from './renderless-password.js'
export default {
components: {
RenderlessPassword
}
}
</script>
65
Figure 9: Rendering with slots.default() and v-slot
The next feature we will add is the password and confirmation fields. We will
also expose a matching property, to see if the password and confirmation are
the same.
First, update renderless-password.js to receive a password and
confirmation prop. We also add the logic to see if the passwords match:
import { computed } from 'vue'
66
return password === confirmation
}
export default {
props: {
password: {
type: String
},
confirmation: {
type: String
}
},
setup(props, { slots }) {
const matching = computed(() => isMatching(
props.password, props.confirmation))
67
<div class="wrapper">
<div class="field">
<label for="password">Password</label>
<input v-model="input.password" id="password" />
</div>
<div class="field">
<label for="confirmation">Confirmation</label>
<input v-model="input.confirmation" id="confirmation" />
</div>
</div>
</renderless-password>
</template>
<script>
import { reactive } from 'vue'
import RenderlessPassword from './renderless-password.js'
export default {
components: {
RenderlessPassword
},
setup(props) {
const input = reactive({
password: '',
confirmation: ''
})
return {
input
}
}
}
</script>
68
You can grab the final styles from the source code. It looks like this:
This works great! The complexity and business logic is nicely abstracted away in
renderless-password. The developer can use the logic to style the component
to suit their application and use case.
Let’s keep going and add a customizable complexity feature, to rate whether a
password is sufficiently complex.
For now, we will implement a very naive complexity check. Most developers will
want to customize this. For this example, we will keep it simple and choose an
algorithm that will rate complexity based on the length of the password:
69
• high: length >= 10
• mid: length >= 7
• low: length >= 5
As with isMatching, we will make a calcComplexity a pure function. Decou-
pled, deterministic, and easily testable.
import { computed } from 'vue'
return 0
}
export default {
props: {
// ...
},
setup(props, { slots }) {
const matching = computed(() => isMatching(
props.password, props.confirmation))
const complexity = computed(() => calcComplexity(
props.password))
70
}
}
</renderless-password>
</template>
<script>
import { reactive } from 'vue'
import RenderlessPassword from './renderless-password.js'
71
export default {
components: {
RenderlessPassword
},
setup(props) {
const input = reactive({
password: '',
confirmation: ''
})
return {
input,
complexityStyle
}
}
}
</script>
<style>
/**
some styles excluded for brevity
see source code for full styling
*/
.complexity {
transition: 0.2s;
height: 10px;
}
.high {
width: 100%;
background: lime;
}
72
.mid {
width: 66%;
background: yellow;
}
.low {
width: 33%;
background: red;
}
</style>
await fireEvent.update(
screen.getByLabelText('Password'), 'this is a long password')
await fireEvent.update(
screen.getByLabelText('Confirmation'), 'this is a long password')
expect(screen.getByRole('password-complexity').classList)
.toContain('high')
expect(screen.getByText('Submit').disabled).toBeFalsy()
})
73
Figure 11: Complexity Indicator
Let’s add the final feature: a button that is only enabled when a valid property
is true. The valid property is exposed by the <renderless-password> and
accessed via v-slot.
import { computed } from 'vue'
export isMatching() {
// ...
}
export calcComplexity() {
// ...
74
}
export default {
props: {
minComplexity: {
type: Number,
default: 3
},
// ... other props ...
},
setup(props, { slots }) {
const matching = computed(() => isMatching(
props.password, props.confirmation))
const complexity = computed(() => calcComplexity(
props.password))
const valid = computed(() =>
complexity.value >= props.minComplexity &&
matching.value)
Validating the form with a valid computed property, derived from matching and
complexity.
I added a valid computed property, based on the result of complexity and
matching. You could make a separate function for this if you wanted to test it in
isolation. If I was going to distribute this npm, I probably would; alternatively,
we can test this implicitly by binding valid to a button’s disabled attribute,
like we are about to do, and then assert against the DOM that the attribute is
set correctly.
Update the usage to include a <button> that binds to valid:
<template>
<renderless-password
:password="input.password"
:confirmation="input.confirmation"
v-slot="{
matching,
complexity,
75
valid
}"
>
<div class="wrapper">
<! -- ... omitted for brevity ... -->
<div class="field">
<button :disabled="!valid">Submit</button>
</div>
</div>
</renderless-password>
</template>
76
Figure 12: Completed Password Complexity Component
Just for fun, I tried making an alternative UI. All I had to do was move around
some markup:
77
See what else you can come up with. I think there is a lot of room for innovation
with the renderless component pattern. There is at least one project using this
pattern, Headless UI - check it out for more inspiration: https://headlessui.dev/.
7.5 Exercises
This section intentionally omitted writing tests to focus on the concepts. Several
techniques regarding tests were mentioned. For practice, try to write the following
tests (find the solutions in the source code):
• Some tests using Testing Library to assert the correct complexity class is
assigned.
• Test that the button is appropriately disabled.
You could also write some tests for the business logic, to make sure we didn’t
miss any edge cases:
• Test the calcComplexity and isMatching functions in isolation.
There are also some improvements you could try making:
• Allow the developer to pass their own calcComplexity function as a prop.
Use this if it’s provided.
• Support passing a custom isValid function, that receives password,
confirmation, isMatching and complexity as arguments.
You can find the completed source code in the GitHub repository under
examples/renderless-password:
https://github.com/lmiller1990/design-patterns-for-vuejs-source-code.
78
8 The Power of Render Functions
You can find the completed source code in the GitHub repository under
examples/renderless-password:
https://github.com/lmiller1990/design-patterns-for-vuejs-source-code.
So far, all the examples in this book have used a <template> to structure the
components. In reality, Vue does a ton of heavy lifting in the background
between writing markup in <template> and rendering content in a browser.
This is primarily handled by one of Vue’s core packages, @vue/compiler-sfc.
Code in <template> is compiled to something called render functions. Several
things happen during this compilation step. Some of these are:
• Directives such as v-if and v-for are converted to regular JavaScript (if
and for or map, for example).
• Optimizations.
• CSS is scoped (if you are using <style scoped>).
While it is generally more ergonomic to write your components with <template>,
there are some situations where it can be beneficial to write the render functions
yourself. One such situation is when writing a very generic UI library. It’s also
good to understand how things work under the hood.
In this section we will build a tab component. The usage will look something
like this:
<template>
<tab-container v-model:activeTabId="activeTabId">
<tab tabId="1">Tab #1</tab>
<tab tabId="2">Tab #2</tab>
<tab tabId="3">Tab #3</tab>
79
Figure 14: Completed Tabs Component
80
The <tab-container> component works by taking a <tab> component with
a tabId prop. This is paired with a <tab-content> component with the
same tabId. Only the <tab-content> where the tabId prop matches the
activeTabId value will be shown. We will dynamically update activeTabId
when a <tab> is clicked.
This example shows a great use case for render functions. Without them, you
might need to write something like this:
<template>
<tab-container v-model:activeTabId="activeTabId">
<tab @click="activeTabId = '1'">Tab #1</tab>
<tab @click="activeTabId = '2'">Tab #2</tab>
<tab @click="activeTabId = '3'">Tab #3</tab>
One of the nice things about render function components is you can create
multiple in the same file. Although I generally like to have one component
per file, in this particular case I have no problem putting <tab-container>,
<tab-content> and <tab> in the same file. The main reason for this is both
81
<tab> and <tab-content> are very simple, and I don’t see any use case where
you would want to use them outside of nesting them in <tab-container>.
Start by creating those two components. We won’t be using a vue file, but just
a plain old js file:
import { h } from 'vue'
render() {
return h(this.$slots.default)
}
}
render() {
return h('div', h(this.$slots.default))
}
}
82
required: true
}
},
...content
})
Now we get to the exciting part - the render function for the <tab-container>
component. It has one prop - activeTabId:
export const TabContainer = {
props: {
activeTabId: String
},
render() {
console.log(this.$slots.default())
}
}
83
setup(props, { slots }) {
console.log(slots.default())
}
}
<script>
import { ref } from 'vue'
import {
Tab,
TabContent,
TabContainer
} from './tab-container.js'
export default {
components: {
Tab,
TabContainer,
TabContent
},
setup() {
return {
activeTabId: ref('1')
}
}
}
</script>
84
In this example, this.$slots.default() would contain four slots (technically,
we can say four VNodes). Two <tab> components and two <tab-content> com-
ponents. To make this more clear we will do some “console driven” development.
Create a new app using the above component as the root component. Open a
browser and open up the console. You should see something like this:
85
Figure 15: Logging Slots (Array of VNodes)
86
An array of four complex objects. These are VNodes - how Vue internally
represents nodes in it’s virtual DOM. I expanded the first one and marked some
of the relevant properties for this section:
87
Figure 16: Detailed View of the Tab VNode
88
The first one is children. This is where the slots go. For example in:
<tab tabId="1">Tab #1</tab>
There is one child, Tab #1. In this case, it is a text node - just some text. It
could be another VNode, which in turn could contain more VNodes - a tree like
structure.
The next marked property is props - this one is pretty obvious, it’s the props
we passed. In this case, there is just one - tabId.
Finally we have type. Type can be a few things - for a regular HTML element,
such as <div>, it would just be div. For a component, it contains the entire
component. In this case, you can see the component we defined - <tab> - which
has props and render attributes.
Now we know how to identify which component a VNode is using - the type
property. Let’s use this knowledge to filter the slots.
The type property is a direct reference to the component the VNode is using.
This means we can match using an object and strict equality. If this sounds a
bit abstract, let’s see it in action and sort the slots into tabs and contents:
export const TabContainer = {
props: {
activeTabId: String
},
render() {
const $slots = this.$slots.default()
const tabs = $slots
.filter(slot => slot.type === Tab)
const contents = $slots
.filter(slot => slot.type === TabContent)
console.log(
tabs,
contents
)
}
}
89
Figure 17: Filtered VNodes
90
The next goal will be to render the tabs. We will also add some classes to get
some nice styling, as well as show which tab is currently selected.
First things first, let’s render something! Enough console driven development.
Import h from vue, and then map over the filtered tabs - I will explain the crazy
(amazing?) h function afterwards:
import { h } from 'vue'
render() {
const $slots = this.$slots.default()
const tabs = $slots
.filter(slot => slot.type === Tab)
.map(tab => {
return h(tab)
})
91
Figure 18: Rendered Tabs
92
You may have noticed I did h(() => tabs) instead of just return tabs. h also
accepts a callback - in which case, it will evaluate the callback function when it
renders. I recommend always returning h(() => /* render function */) for
the final value in render - if you don’t, you may run into subtle caching issues.
You can also return an array from render - this is known as a fragment, where
there is no root node.
If this looks confusing, don’t worry - here comes the h crash course.
return [
h(() => e1),
h(() => e2),
h(() => e3)
]
}
}
93
This will create a single <div> - not very useful. The second argument can be
attributes, represented by an object.
const el = h('div', { class: 'tab', foo: 'bar' })`
You can also pass more VNodes, created with nested calls to h:
const el = h(
'div',
{
class: 'tab',
foo: 'bar'
},
[
h(
'span',
{},
['Hello world!']
)
]
)
As shown above, you are not just limited to standard HTML elements. You can
pass a custom component to h, too:
94
const Tab = {
render() {
return h('span')
}
}
Now we have a better understanding of h, we can add some classes to the <tab>
components. Each <tab> will have a tab class, and the active tab will have an
active class. Update the render function:
export const TabContainer = {
props: {
activeTabId: String
},
render() {
const $slots = this.$slots.default()
const tabs = $slots
.filter(slot => slot.type === Tab)
.map(tab => {
return h(
tab,
{
class: {
tab: true,
active: tab.props.tabId === this.activeTabId
}
}
)
})
95
}
}
96
Figure 19: Dynamic Classes
97
8.8 Event Listeners in Render Functions
The active tab needs to update when the user clicks a tab. Let’s implement that.
Event listeners are much the same as attributes like class.
{
class: {
tab: true,
active: tab.props.tabId === this.activeTabId
},
onClick: () => {
this.$emit('update:activeTabId', tab.props.tabId)
}
}
98
Figure 20: Emitting Events in Render Functions
99
8.9 Filtering Content
The last feature we need to implement is rendering the content - but only the
content that matches the activeTabId. Instead of using filter to get the
contents VNodes, we should use find - there will only ever be one tab selected
at any given time. Use find instead of filter in the render function:
const content = $slots.find(slot =>
slot.type === TabContent &&
slot.props.tabId === this.activeTabId
)
render() {
const $slots = this.$slots.default()
const tabs = $slots
.filter(slot => slot.type === Tab)
.map(tab => {
return h(
tab,
{
class: {
tab: true,
active: tab.props.tabId === this.activeTabId
},
onClick: () => {
this.$emit('update:activeTabId', tab.props.tabId)
}
}
)
})
return [
100
h(() => h('div', { class: 'tabs' }, tabs)),
h(() => h('div', { class: 'content' }, content)),
]
}
}
101
8.10 Testing Render Function Components
Now that we finished the implementation, we should write a test to make sure
everything continues working correctly. Writing a test is pretty straight forward -
the render function from Testing Library works fine with render functions (vue
files are compiled into render functions, so all the tests we’ve been writing have
been using render functions under the hood).
import { render, screen, fireEvent } from '@testing-library/vue'
import App from './app.vue'
fireEvent.click(screen.getByText('Tab #2'))
await screen.findByText('Content #2')
})
8.11 Exercises
102
Figure 22: Typesafe Component with Render Function
103
9 Dependency Injection with Provide and Inject
You can find the completed source code in the GitHub repository under
examples/provide-inject:
https://github.com/lmiller1990/design-patterns-for-vuejs-source-code.
104
Figure 23: Completed demo app
105
9.1 A Simple Store
Let’s quickly define a dead simple store. We won’t have a complex API like
Vuex - just a class with some methods. Let’s start with a reactive state, and
expose it in readonly fashion via a getState function.
import { reactive, readonly } from 'vue'
constructor(state) {
this.#state = reactive(state)
}
getState() {
return readonly(this.#state)
}
}
describe('store', () => {
it('seeds the initial state', () => {
const store = new Store({
users: []
})
expect(store.getState()).toEqual({ users: [] })
})
})
106
9.2 Usage via import
Let’s get something rendering before we go. Export a new instance of the store:
import { reactive, readonly } from 'vue'
<script>
import { computed } from 'vue'
import { store } from './store.js'
export default {
setup() {
return {
users: computed(() => store.getState().users)
}
}
}
</script>
107
Figure 24: Displaying a user from the store state.
It works! Good progress - I added a tiny bit of CSS as well, grab that from the
source code.
This single shared store is known as a global singleton.
We will allowing adding more users via a form - but first let’s add a UI test
using Testing Library.
import { render, screen, fireEvent } from '@testing-library/vue'
import { Store } from './store.js'
import Users from './users.vue'
describe('store', () => {
it('seeds the initial state', () => {
// ...
})
108
provide: {
store: new Store({
users: []
})
}
}
})
// ...
addUser(user) {
this.#state.users.push(user)
}
}
addUser can access the private state because it is declared in the Store class.
I also removed the initial user, Alice, from the store. Update the tests - we can
test addUser in isolation.
109
describe('store', () => {
it('seeds the initial state', () => {
// ...
})
expect(store.getState()).toEqual({
users: [{ name: 'Alice' }]
})
})
})
<script>
import { ref, computed } from 'vue'
import { store } from './store.js'
export default {
110
setup() {
const username = ref('')
const handleSubmit = () => {
store.addUser({ name: username.value })
username.value = ''
}
return {
username,
handleSubmit,
users: computed(() => store.getState().users)
}
}
}
</script>
111
Figure 25: Completed app
Everything looks to be working on the surface, but we will eventually run into a
problem as our application grows: shared state across tests. We have a single
store instance for all of our tests - when we mutate the state, this change will
impact all the other tests, too.
Ideally each test should run in isolation. We can’t isolate the store if we are
importing the same global singleton into each of our tests. This is where provide
and inject come in handy.
This diagram, taken from this official documentation, explains it well:
112
Figure 26: Provide/Inject diagram. Credit: Vue documentation.
Let’s say you have a component, Parent.vue, that looks like something this:
<template>
<child />
</template>
<script>
import { provide } from 'vue'
export default {
setup() {
const theColor = 'blue'
provide('color', theColor)
}
}
</script>
We are making a color variable available to any child component that might
want access to it, no matter how deep in the component hierarchy it appears.
113
Child.vue might look like this:
<template>
<!-- renders Color is: blue -->
Color is: {{ color }}
</template>
<script>
import { inject } from 'vue'
export default {
setup() {
const color = inject('color')
return {
color
}
}
}
</script>
You can pass anything to provide - including a reactive store. Let’s do that.
Head to the top level file where you create your app (mine is index.js; see the
source code for a complete example):
import { createApp } from 'vue'
import { store } from './examples/provide-inject/store.js'
import Users from './examples/provide-inject/users.vue'
<script>
import { ref, inject, computed } from 'vue'
114
export default {
setup() {
const store = inject('store')
const username = ref('')
return {
username,
handleSubmit,
users: computed(() => store.getState().users)
}
}
}
</script>
describe('store', () => {
it('seeds the initial state', () => {
// ...
})
115
})
}
}
})
We can write a little abstraction to make using our store a bit more ergonomic.
Instead of typing const store = inject('store') everywhere, it would be
nice to just type const store = useStore().
Update the store:
import { reactive, readonly, inject } from 'vue'
A useStore composable.
Now update the component:
<template>
<!-- ... -->
</template>
<script>
116
import { ref, computed } from 'vue'
import { useStore } from './store.js'
export default {
setup() {
const store = useStore()
const username = ref('')
return {
username,
handleSubmit,
users: computed(() => store.getState().users)
}
}
}
</script>
117
9.7 Exercises
118
10 Modular Components, v-model, and the
Strategy Pattern
You can find the completed source code in the GitHub repository under
examples/reusable-date-time:
https://github.com/lmiller1990/design-patterns-for-vuejs-source-code.
In this section we will author a reusable date component. Usage will be like this:
<date-time
v-model="date"
:serialize="..."
:deserialize="..."
/>
The goal - a <datetime> component that works with any DateTime library via
the strategy pattern.
The finished component will look like this:
119
Figure 27: Completed DateTime Component
There are three props: v-model, serialize and deserialize. More on what
serialize and deserialize are soon.
The idea is that the date value passed to v-model can use whichever DateTime
library the developer wants to use. We want to allow developers to choose their
a DateTime library, instead of mandating a specific one.
Some applications use the native JavaScript Date object (don’t do this; it’s not
a very good experience). Older applications will often use Moment and newer
ones common opt for Luxon.
I’d like to support both - and any other library the user might choose to use! In
other words, we want the component to be agnostic - it should not be coupled
to a specific date time library.
One way to handle this would be to pick a simple format of our own, for
example YYYY-MM-DD, and then have the user wrap the component and provide
120
a custom integration layer. For example a user wanting to use Luxon might
wrap <date-time> in their own <date-time-luxon> component:
<template>
<date-time
:modelValue="date"
@update:modelValue="updateDate"
/>
</template>
<script>
import { ref } from 'vue'
import { DateTime } from 'luxon'
export default {
setup() {
return {
date: ref(DateTime.local()),
updateDate: (value) => {
// some logic to turn value which is
// YYYY-MM-DD into Luxon DateTime
}
}
}
}
</script>
121
/>
122
luxon.Datetime(). The input and output is a Luxon DateTime - the
developer doesn’t need to know or care about the internal representation.
Before implementing the strategy pattern (in this example, the serialize
and deserialize functions), let’s write the base for <date-time>. It will use
v-model. This means we receive a modelValue prop and update the value by
emitting a update:modelValue event. To keep things simple, I will just use 3
<input> elements for the year, month and day.
<template>
<input :value="modelValue.year" @input="update($event, 'year')" />
<input :value="modelValue.month" @input="update($event, 'month')" />
<input :value="modelValue.day" @input="update($event, 'day')" />
<pre>
Internal date is:
{{ modelValue }}
</pre>
</template>
<script>
import { reactive, watch, computed } from 'vue'
import { DateTime } from 'luxon'
export default {
props: {
modelValue: {
type: Object
},
},
setup(props, { emit }) {
const update = ($event, field) => {
const { year, month, day } = props.modelValue
let newValue
if (field === 'year') {
newValue = { year: $event.target.value, month, day }
}
if (field === 'month') {
newValue = { year, month: $event.target.value, day }
}
if (field === 'day') {
newValue = { year, month, day: $event.target.value }
}
123
emit('update:modelValue', newValue)
}
return {
update
}
}
}
</script>
<script>
import { ref } from 'vue'
import dateTime from './date-time.vue'
export default {
components: { dateTime },
setup() {
const dateLuxon = ref({
year: '2020',
month: '01',
day: '01',
})
return {
dateLuxon
}
}
}
</script>
124
Figure 29: Rendering the Date Inputs
We have established the internal API. This is how the <date-time> component
will manage the value. For notation purposes, if we were to write an interface in
TypeScript, it would look like this:
interface InternalDateTime {
year?: string
month?: string
125
day?: string
}
We will now work on the deserialize prop, which is a function that will convert
any object (so a Luxon DateTime object, or Moment Moment object) into an
InternalDateTime. This is the representation the <date-time> component
uses internally.
I will use Luxon’s DateTime to demonstrate. You can create a new DateTime
like this:
import { DateTime } from 'luxon'
The goal is to get from our input from v-model, in this case a Luxon DateTime,
to our internal representation, InternalDateTime. This conversion is trivial in
the case of Luxon’s DateTime. You can just do date.get() passing in year,
month or day. So our deserialize function looks like this:
// value is what is passed to `v-model`
// in this example a Luxon DateTime
// we need to return an InternalDateTime
export function deserialize(value) {
return {
year: value.get('year'),
month: value.get('month'),
day: value.get('day')
}
}
126
<template>
<date-time
v-model="dateLuxon"
:deserialize="deserialize"
/>
{{ dateLuxon.toISODate() }}
</template>
<script>
import { ref } from 'vue'
import dateTime from './date-time.vue'
import { DateTime } from 'luxon'
export default {
components: { dateTime },
setup() {
const dateLuxon = ref(DateTime.fromObject({
year: '2019',
month: '01',
day: '01',
}))
return {
dateLuxon,
deserialize
}
}
}
</script>
127
Internal date is:
{{ date }}
</pre>
</template>
<script>
import { reactive, computed } from 'vue'
export default {
props: {
modelValue: {
type: Object
},
deserialize: {
type: Function
}
},
setup(props, { emit }) {
const date = computed(() => {
return props.deserialize(props.modelValue)
})
return {
update,
date
}
}
}
</script>
128
The main changes are:
1. We now need to use a computed property for modelValue, to ensure it is
correctly transformed into our InternalDateTime representation.
2. We use deserialize on the modelValue in the update function when
preparing to update modelValue.
129
Now would generally be a good time to write a test for the deserialize function.
Notice I exported it independently of the Vue component, and it does not use
the Vue reactivity system. This is intentional. It’s a pure function, so it’s very
easy to test. For brevity, the tests are not shown, but you can find them in the
GitHub repository.
This implementation currently works - kind of - it displays the correct values in
the <input> elements, but you cannot update the value. We need the opposite
of deserialize - serialize.
<script>
import { ref } from 'vue'
import dateTime from './date-time.vue'
import { DateTime } from 'luxon'
// ...
130
export default {
// ...
return {
dateLuxon,
deserialize,
serialize
}
}
}
</script>
I added a :serialize prop and returned serialize from the setup function.
Next, we need to call serialize every time we try to update modelValue.
Update <date-time>:
<template>
<!--
Omitted for brevity.
Nothing to change here right now.
-->
</template>
<script>
import { computed } from 'vue'
import { DateTime } from 'luxon'
export default {
props: {
modelValue: {
type: Object
},
serialize: {
type: Function
},
deserialize: {
type: Function
}
},
setup(props, { emit }) {
// ...
131
const update = ($event, field) => {
const { year, month, day } = props.deserialize(props.modelValue)
let newValue
// ...
emit('update:modelValue', props.serialize(newValue))
}
return {
update,
date
}
}
}
</script>
All that changed was declaring the serialize prop and calling props.serialize
when emitting the new value.
It works! Kind of - as long as you only enter value numbers. If you enter a 0 for
the day, all the inputs show NaN. We need some error handling.
132
Figure 31: Serializing/Deserializing without error handling.
133
10.5 Error Handling
In the case of an error - either we could not serialize or deserialize the value - we
will just return the current input value, and give the user a chance to fix things.
Let’s update serialize to be more defensive:
export function serialize(value) {
try {
const obj = DateTime.fromObject(value)
if (obj.invalid) {
return
}
} catch {
return
}
return DateTime.fromObject(value)
}
In the case that we failed to serialize the value, we just return undefined.
Update the emit in <date-time> to use this new logic; if the value is invalid,
we simply do not update modelValue:
export default {
props: {
// ...
},
setup(props, { emit }) {
// ...
const update = ($event, field) => {
const { year, month, day } = props.modelValue
let newValue
// ...
return {
update,
date
134
}
}
}
10.6 Deploying
The goal here was to create a highly reusable <date-time> component. If I was
going to release this on npm, there is a few things I’d do.
1. Remove serialize and deserialize from the <date-time> component
and put them into another file. Perhaps one called strategies.js.
2. Write a number of strategies for popular DateTime libraries (Luxon, Mo-
ment etc).
3. Build and bundle the component and strategies separately.
This will allow developers using tools like webpack or rollup to take advantage
of “tree shaking”. When they build their final bundle for production, it will only
include the <date-time> component and the strategy they are using. It will
also allow the developer to provide their own more opinionated strategy.
To make the component even more reusable, we could consider writing it as
a renderless component, like the one described in the renderless components
section.
10.7 Exercises
• We did not add any tests for serialize or deserialize; they are pure
functions, so adding some is trivial. See the source code for some tests.
• Add support for another date library, like Moment. Support for Moment
is implemented in the source code.
• Add hours, minutes, seconds, and AM/PM support.
• Write some tests with Testing Library; you can use fireEvent.update to
update the value of the <input> elements.
You can find the completed source code in the GitHub repository under
examples/reusable-date-time:
https://github.com/lmiller1990/design-patterns-for-vuejs-source-code.
135
11 Grouping Features with Composables
You can find the completed source code in the GitHub repository under exam-
ples/composition:
https://github.com/lmiller1990/design-patterns-for-vuejs-source-code.
Vue 3’s flagship feature is The Composition API; it’s main selling point is to
easily group and reuse code by feature. In this section we will see some techniques
to write testable composables by building a tic tac toe game, including undo
and redo.
136
The API we will end with looks like this:
export default {
setup() {
const {
currentBoard,
makeMove,
undo,
redo
} = useTicTacToe()
return {
makeMove,
currentBoard
}
}
}
Final API.
currentBoard is a computed property that looks like this:
[
['x', 'o', '-'],
['x', 'o', 'x'],
['-', 'o', '-']
]
Calling makeMove({ row: 0, col: 1 }) would yield the following board (where
o goes first)
[
['-', 'o', '-'],
['-', '-', '-'],
['-', '-', '-']
]
We will also support undo and redo, so you can go back and see see how the
game progressed. Implementing this will be an exercise, and the solution is
137
included in the final source code.
Let’s start with some way to maintain the game state. I will call this variable
initialBoard:
const initialBoard = [
['-', '-', '-'],
['-', '-', '-'],
['-', '-', '-']
]
Initial board.
Before diving too far into the game logic, let’s get something rendering. Remem-
ber we want to keep a history of the game for undo/redo? This means instead of
overriding the current game state each move, we should just create a new game
state and push it into an array. Each entry will represent a move in the game.
We also need the board to be reactive, so Vue will update the UI. We can use
ref for this. Update the code:
import { ref, readonly } from 'vue'
return {
boards: readonly(boards)
}
}
138
v-for="(col, colIdx) in row"
class="col"
>
{{ col }}
</div>
</div>
</template>
<script>
import { useTicTacToe } from './tic-tac-toe.js'
export default {
setup() {
const { boards } = useTicTacToe()
return {
boards
}
}
}
</script>
<style>
.row {
display: flex;
}
.col {
border: 1px solid black;
height: 50px;
width: 50px;
}
</style>
139
Figure 33: Rendered game board
140
['-', '-', '-']
]
return {
boards: readonly(boards),
currentBoard: computed(() => boards.value[boards.value.length - 1])
}
}
<script>
import { useTicTacToe } from './tic-tac-toe.js'
export default {
setup() {
const { boards, currentBoard } = useTicTacToe()
return {
boards,
currentBoard
}
}
}
</script>
141
11.3 Tests
We’ve written a little too much code without any tests for my liking. Now is a
good time to write some, which will reveal some (potential) problems with our
design.
import { useTicTacToe } from './tic-tac-toe.js'
describe('useTicTacToe', () => {
it('initializes state to an empty board', () => {
const initialBoard = [
['-', '-', '-'],
['-', '-', '-'],
['-', '-', '-']
]
const { currentBoard } = useTicTacToe()
expect(currentBoard.value).toEqual(initialBoard)
})
})
142
return {
boards: readonly(boards),
currentBoard: computed(() => boards.value[boards.value.length - 1])
}
}
expect(currentBoard.value).toEqual(initialState)
})
})
The final feature we will add is the ability for a player to make a move. We need
to keep track of the current player, and then update the board by pushing the
next game state into boards. Let’s start with a test:
describe('makeMove', () => {
it('updates the board and adds the new state', () => {
const game = useTicTacToe()
game.makeMove({ row: 0, col: 0 })
expect(game.boards.value).toHaveLength(2)
143
expect(game.currentPlayer.value).toBe('x')
expect(game.currentBoard.value).toEqual([
['o', '-', '-'],
['-', '-', '-'],
['-', '-', '-']
])
})
})
Testing makeMove.
There isn’t anything too surprising here. After making a move, we have two
game states (initial and the current one). The current player is now x (since o
goes first). Finally, the currentBoard should be updated.
One thing you should look out for is code like this:
game.makeMove({ row: 0, col: 0 })
144
const newBoard = JSON.parse(
JSON.stringify(boards.value)
)[boards.value.length - 1]
newBoard[row][col] = currentPlayer.value
currentPlayer.value = currentPlayer.value === 'o' ? 'x' : 'o'
boards.value.push(newBoard)
}
return {
makeMove,
boards: readonly(boards),
currentPlayer: readonly(currentPlayer),
currentBoard: computed(() => boards.value[boards.value.length - 1])
}
}
Implementing makeMove.
This gets the test to pass. As mentioned above we are using the somewhat dirty
JSON.parse(JSON.stringify(...)) to clone the board and lose reactivity.
I want to get non reactive copy of the board - just a plain JavaScript array.
Somewhat surprisingly, [...boards.value[boards.value.length - 1]] does
not work - the new object is still reactive and updates when the source array is
mutated. This means we are mutating the game history in boards! Not ideal.
What you would need to do is this:
const newState = [...boards.value[boards.value.length - 1]]
const newRow = [...newState[row]];
This works - newRow is now a plain, non-reactive JavaScript array. I don’t think
it’s immediately obvious what is going on, however - you need to know Vue and
the reactivity system really well to understand why it’s necessary. On the other
hand, I think the JSON.parse(JSON.stringify(...)) technique is actually a
little more obvious - most developers have seen this classic way to clone an object
at some point or another.
You can pick whichever you like best. Let’s continue by updating the usage:
<template>
<div v-for="(row, rowIdx) in currentBoard" class="row">
<div
v-for="(col, colIdx) in row"
class="col"
@click="makeMove({ row: rowIdx, col: colIdx })"
>
{{ col }}
145
</div>
</div>
</template>
<script>
import { useTicTacToe } from './tic-tac-toe.js'
export default {
setup() {
const { boards, currentBoard, makeMove } = useTicTacToe()
return {
boards,
currentBoard,
makeMove
}
}
}
</script>
146
Figure 34: Completed Game
That’s it! Everything now works. The game is now playable - well, you can
make moves. There are several problems:
1. No way to know if a player has won.
2. You can make an invalid move (for example, going on a square that is
already taken).
3. Did not implement undo/redo.
Fixing/implementing these is not very difficult and will be left as an exercise.
You can find the solutions in the source code. Undo/redo is probably the most
interesting one - you should try and implement this yourself before looking at
the solutions.
147
11.6 Conclusion
We saw how you can isolate business logic in a composable, making it testable
and reusable. We also discussed some trade-offs of our approach - namely,
coupling the business logic to Vue’s reactivity system. This concept will be
further explored in the next section.
11.7 Exercises
1. Write some tests with Testing Library to ensure the UI is working correctly.
See the GitHub repository for the solutions.
2. Do not allow moving on a square that is already taken.
3. Add a check after each move to see if a player has won. Display this
somewhere in the UI.
4. Implement undo and redo.
You can find the completed source code in the GitHub repository under exam-
ples/composition:
https://github.com/lmiller1990/design-patterns-for-vuejs-source-code.
148
12 Functional Core, Imperative Shell - Im-
mutable Logic, Mutable Vue
You can find the completed source code in the GitHub repository under
examples/composition-functional:
https://github.com/lmiller1990/design-patterns-for-vuejs-source-code.
In the previous chapter, we build a Tic Tac Toe game, encapsulating the logic
in a composable. We consciously decided to couple our implementation to Vue,
when we used reactivity APIs like computed and ref in the business logic.
In this chapter, we will explore an paradigm best characterized as “functional
core, imperative shell”. We will come back to this name and explain what it
means soon.
The goal is to refactor the Tic Tic Toe logic to be more in line with the functional
programming paradigm - this means pure functions and no mutation. Since
we are avoiding mutation, this mean we will decoupled the logic from Vue’s
reactivity system, which relies on mutation and side effects.
Let’s start with makeMove, which is full of mutation. In our previous implemen-
tation, makeMove looks like this:
function makeMove({ row, col }) {
const newBoard = [...boards.value[boards.value.length - 1]]
newBoard[row][col] = currentPlayer.value
currentPlayer.value = currentPlayer.value === 'o' ? 'x' : 'o'
boards.value.push(newBoard)
}
149
function makeMove(board: Board, { col, row, counter }: Options): Board
The new makeMove will return an updated board based on it’s arguments.
In other words, makeMove needs to receive all required arguments to create a
new board, and should return a new board. This makes it pure; the return value
is determined exclusively by the inputs.
You may be wondering: if we cannot mutate anything, how do we get anything
done? How will we update the UI?
The answer is that while we only avoid mutation in the business logic. This
is the “functional core” part of the paradigm. All side effects, mutation and
unpredictable actions, such as updating the DOM and listening for user input
will be handled in a thin layer. This thin layer is the imperative shell part of the
paradigm. The imperative shell wraps the functional core (the business logic)
with Vue’s reactivity APIs. All mutation will occur in the imperative shell.
Figure 35: Functional Core, Imperative Shell. Image Credit: mokagio (Twitter)
150
In this diagram the solid white circles represents the “functional core”. These are
a collection of pure functions that are written in plain JavaScript - no reactivity
and no global variables. This includes methods like the new makeMove function
we are about to write.
The thin layer surrounding the solid circles represents the “imperative shell”.
In this system, it is the useTicTacToe composable - a thin layer written using
Vue’s reactivity system, marrying the functional business logic and the UI layer.
The solid rectangles on the right represent interactions between the system and
the outside world - things like user input, updating the DOM, the response to a
HTTP request to a third party system or a push notification.
By making the business logic mutation free, it’s very easy to test. We will
then test the imperative shell, or the Vue integration, using Testing Library - a
library designed for this very purpose - to test Vue components. We won’t need
too many tests, since all the complexity and edge cases will be covered in the
functional core tests.
The final API is going to be the same:
import { useTicTacToe } from './tic-tac-toe.js'
export default {
setup() {
const { currentBoard, makeMove } = useTicTacToe()
return {
currentBoard,
makeMove
}
}
}
Let’s start with the functional core, starting with a createGame function:
/**
* Core Logic
* Framework agnostic
*/
export const initialBoard = [
['-', '-', '-'],
['-', '-', '-'],
['-', '-', '-']
151
]
So far, no mutation.
While we could have just done createGame without passing any arguments, this
makes it easy to seed an initial state for testing. Also, we avoid relying on a
global variable.
A test is so trivial it’s almost pointless to write, but let’s do it anyway:
describe('useTicTacToe', () => {
it('initializes state to an empty board', () => {
const expected = [
['-', '-', '-'],
['-', '-', '-'],
['-', '-', '-']
]
expect(createGame(initialBoard)).toEqual(expected)
})
})
Then bulk of the logic is in the makeMove function. To update the board, we
need the current game state, the column and row to update, and the counter (x
or o). So those will be the arguments we pass to the function.
export function makeMove(board, { col, row, counter }) {
// copy current board
// return copy with updated cell
}
152
describe('makeMove', () => {
it('returns a new updated board', () => {
const board = createGame()
const updatedBoard = makeMove(board, {
row: 0,
col: 0,
counter: 'o'
})
expect(updatedBoard).toEqual([
['o', '-', '-'],
['-', '-', '-'],
['-', '-', '-']
])
})
})
153
a for loop and mutation, I started to find this style of code more concise, and
more importantly, less prone to bugs.
We can make this a lot more concise! This is optional; there is some merit to
verbose, explicit code too. Let’s see the concise version. You can make a decision
which one you think is more readable.
export function makeMove(board, { col, row, counter }) {
return board.map((theRow, rowIdx) =>
theRow.map((cell, colIdx) =>
rowIdx === row && colIdx === col
? counter
: cell
)
)
}
154
return {
currentBoard,
makeMove: move
}
}
The composable integrates the functional core with Vue’s reactivity system - the
"imperative shell" around the functional core.
I added an empty move function, assigning it to makeMove in the return value of
useTicTacToe. We will be implementing that soon.
Let’s get something rendering:
<template>
<div v-for="(row, rowIdx) in currentBoard" class="row">
<div
v-for="(col, colIdx) in row"
class="col"
:data-test="`row-${rowIdx}-col-${colIdx}`"
@click="makeMove({ row: rowIdx, col: colIdx })"
>
{{ col }}
</div>
</div>
</template>
<script>
import { useTicTacToe } from './tic-tac-toe.js'
export default {
setup(props) {
const { currentBoard, makeMove } = useTicTacToe()
return {
currentBoard,
makeMove
}
}
}
</script>
<style>
.row {
display: flex;
}
155
.col {
border: 1px solid black;
height: 50px;
width: 50px;
}
</style>
156
12.5 Integrating makeMove
The last thing we need to do is wrap the functional, stateless makeMove function
from the functional core. This is easy:
const move = ({ col, row }) => {
const newBoard = makeMove(
currentBoard.value,
{
col,
row,
counter: counter.value
}
)
boards.value.push(newBoard)
counter.value = counter.value === 'o' ? 'x' : 'o'
}
157
Figure 37: Rendered game board
From a user point of view, nothing has changed, and we can verify this by reusing
the UI test (first exercise from the previous section):
import { render, fireEvent, screen } from '@testing-library/vue'
import TicTacToeApp from './tic-tac-toe-app.vue'
describe('TicTacToeApp', () => {
it('plays a game', async () => {
render(TicTacToeApp)
await fireEvent.click(screen.getByTestId('row-0-col-0'))
await fireEvent.click(screen.getByTestId('row-0-col-1'))
158
await fireEvent.click(screen.getByTestId('row-0-col-2'))
expect(screen.getByTestId('row-0-col-0').textContent).toContain('o')
expect(screen.getByTestId('row-0-col-1').textContent).toContain('x')
expect(screen.getByTestId('row-0-col-2').textContent).toContain('o')
})
})
The UI test from previous section, ensuring the behavior has not changed.
There is one last improvement we can make. We currently wrap the stateless
makeMove function:
const move = ({ col, row }) => {
const newBoard = makeMove(
currentBoard.value,
{
col,
row,
counter: counter.value
}
)
boards.value.push(newBoard)
counter.value = counter.value === 'o' ? 'x' : 'o'
}
Ideally all the business logic should be in the functional core. This includes
changing the counter after each move. I think this is part of the core gameplay
- not the UI. For this reason I would like to move counter.value === 'o' ?
'x' : 'o' into the functional core.
Update makeMove to change the counter after updating the board, and return
an object representing the new board as well as the updated counter:
export function makeMove(board, { col, row, counter }) {
const newBoard = board.map((theRow, rowIdx) =>
// ...
)
const newCounter = counter === 'o' ? 'x' : 'o'
return {
newBoard,
newCounter
}
159
}
Now makeMove handles updating the counter, as well as the board. Update move
to use the new return value:
const move = ({ col, row }) => {
const { newBoard, newCounter } = makeMove(
currentBoard.value,
{
col,
row,
counter: counter.value
}
)
boards.value.push(newBoard)
counter.value = newCounter
}
Finally, since we changed the return value, the makeMove test needs to be updated
(the UI test using Testing Library still passes, since the actual behavior from
the user’s point of view has not changed):
describe('makeMove', () => {
it('returns a new updated board and counter', () => {
const board = createGame(initialBoard)
const { newBoard, newCounter } = makeMove(board, {
row: 0,
col: 0,
counter: 'o'
})
expect(newCounter).toBe('x')
expect(newBoard).toEqual([
['o', '-', '-'],
['-', '-', '-'],
['-', '-', '-']
])
})
})
All the tests are now passing. I think this refactor is a good one; we pushed the
business logic into the functional core, where it belongs.
160
12.7 Reflections and Philosophy
This section explores concepts that I think separates greate developers from
everyone else. Separation of concerns is really about understanding what a
function should do, and where to draw the lines between the different parts of a
system.
There are some easy ways to see if you are separating your Vue UI logic from
your business logic, or in a more general sense, your imperative shell from your
functional core:
• are you accessing Vue reactivity APIs in your business logic? This usually
comes in the form of .value for accessing the values of computed and ref.
• are you relying on global or pre-defined state?
This also prompts another question: what and how should we be testing in our
functional core and imperative shell? In the previous section, we tested both
in one go - they were so tightly coupled together, so this was the natural way
to test them. This worked out fine for that very simple composable, but can
quickly become complex. I like to have lots of tests around my business logic.
If you write them like we did here - pure functions - they are very easy to test,
and the tests run really quickly.
When testing the imperative shell (in this case the Vue UI layer using Testing
Library) I like to focus on more high level tests from a user point of view - clicking
on buttons and asserting the correct text and DOM elements are rendered. The
imperative shell doesn’t (and shouldn’t) know about how the functional core
works - these tests focus on asserting the behavior of the application from the
user’s perspective.
There is no one true way to write applications. It is also very hard to transition
an application from a mutation heavy paradigm to the style discussed in this
chapter.. I am more and more of the opinion that coupling Vue’s reactivity to
your composables and business logic is generally not a good idea - this simple
separate makes things a whole lot more easy to reason about, test, and has very
little downside (maybe a bit more code, but I don’t see this is a big deal).
I think you should extract your logic into a functional core that is immutable
and does not rely on shared state. Test this in isolation. Next, you write and
test your imperative shell - in this case the useTicTacToe composable, in the
context of this chapter - an test is using something like Testing Library (or a
similar UI testing framework). These test are not testing business logic as such,
but that your integration layer (the composable and Vue’s reactivity) is correctly
hooked up to your functional core.
161
12.8 Exercises
Repeat the exercises from the last chapter - undo/redo, defensive checks to
prevent illegal moves, check if a player has won the game and display it on the
UI.
You can find the completed source code in the GitHub repository under
examples/composition-functional:
https://github.com/lmiller1990/design-patterns-for-vuejs-source-code.
162