State in the right place
Assignment
Martina Hostettler has a new wish.
As a member, I want to see my requests everywhere in the app, so that I know what I have already requested.
The volunteer hesitates. App already provides the catalog, the favorites and the signed-in member, and every new page needs some of it again. Before a fourth piece comes along, the volunteer wants to settle where each piece belongs. The acceptance criteria are:
- For favorites, login, filter and requests, it is decided and explained where they live.
- The header and the list show the same favorites, and no component keeps a copy of its own.
- The header and the overview show the same requests, no matter where someone requests a thing.
Learning goals
After this chapter you can
- tell local, shared and global state apart,
- recognize data from the server and state in the address,
- lift state into the parent and pass it on with props and events,
- put shared state into a module with functions,
- explain when an app needs no store.
Kinds of state
Section titled “Kinds of state”State is the data that changes while someone works with the app and that the page shows. The running case app already has plenty of it. The search field holds its text, the dialog knows whether it is open, App holds the catalog, the favorites and the member, and since part 6 search, category and sort order live in the address. These places differ in who needs the state and how long it should live.
| Kind | Example in the running case app | Place |
|---|---|---|
| local | whether the dialog is open | a ref in the component |
| shared | the favorites for header and list | the common parent component |
| global | the signed-in member | a module or a store |
| from the server | catalog and requests | the server, a copy in the browser |
| in the address | search, category and sort order | params and query |
The figure asks its questions in an order that leads to an answer. The address comes first, because whatever someone wants to share or reload has to live there. The server follows, because the app can only ask for data that belongs to others. Only after that does it matter how many components need a value.
Lifting state into the parent
Section titled “Lifting state into the parent”When two siblings need the same state, it belongs in their common parent. The parent passes it on as a prop and changes it when a child reports an event, as in part 4.
App.vue
<script setup>import { ref } from 'vue'
import AppHeader from './AppHeader.vue'import ItemList from './ItemList.vue'
// Header and list both need the favorites, so they live in their common parentconst favorites = ref([])
function toggle(id) { favorites.value = favorites.value.includes(id) ? favorites.value.filter((entry) => entry !== id) : [...favorites.value, id]}</script>
<template> <AppHeader :count="favorites.length" /> <ItemList :favorites="favorites" @toggle="toggle" /></template>AppHeader.vue
<script setup>defineProps({ count: { type: Number, required: true } })</script>
<template> <header> <strong>Lorraine Commons</strong> <span>Favorites: {{ count }}</span> </header></template>
<style scoped>header { display: flex; justify-content: space-between; gap: 1rem;}</style>ItemList.vue
<script setup>import { things } from './things.js'
defineProps({ favorites: { type: Array, required: true } })// The list only reports a click, the parent changes the favoritesconst emit = defineEmits(['toggle'])</script>
<template> <ul> <li v-for="thing in things" :key="thing.id"> {{ thing.name }} <button type="button" :aria-pressed="favorites.includes(thing.id)" @click="emit('toggle', thing.id)">Favorite</button> </li> </ul></template>
<style scoped>[aria-pressed='true'] { font-weight: 700;}</style>things.js
// A few things of the catalogexport const things = [ { id: 1, name: 'Drill', deposit: 20, availableFrom: null }, { id: 2, name: 'Tent for four', deposit: 50, availableFrom: '2026-11-12' }, { id: 5, name: 'Jigsaw', deposit: 20, availableFrom: null }, { id: 6, name: 'Projector', deposit: 80, availableFrom: null }, { id: 15, name: 'Circular saw', deposit: 40, availableFrom: null },]According to the Vue documentation, this gets tedious in deep trees, because the props travel through components that don't need them. This is called prop drilling. For a subtree, provide and inject from chapter 4.5 help, and that's how the running case app has handled it since part 6.
A module for shared state
Section titled “A module for shared state”A JavaScript module runs only once, and every component that imports it gets the same objects. A ref in a module is therefore already global state, without any library.
App.vue
<script setup>import AppHeader from './AppHeader.vue'import ItemList from './ItemList.vue'import RequestList from './RequestList.vue'</script>
<template> <AppHeader /> <ItemList /> <RequestList /></template>AppHeader.vue
<script setup>import { requests } from './requests.js'</script>
<template> <header> <strong>Lorraine Commons</strong> <span>Requests: {{ requests.length }}</span> </header></template>
<style scoped>header { display: flex; justify-content: space-between; gap: 1rem;}</style>ItemList.vue
<script setup>import { add } from './requests.js'import { things } from './things.js'</script>
<template> <ul> <li v-for="thing in things" :key="thing.id"> {{ thing.name }} <button type="button" @click="add(thing)">Request</button> </li> </ul></template>RequestList.vue
<script setup>import { remove, requests } from './requests.js'</script>
<template> <h2>My requests</h2> <p v-if="requests.length === 0">No requests yet.</p> <ul v-else> <li v-for="request in requests" :key="request.id"> {{ request.name }} <button type="button" @click="remove(request.id)">Withdraw</button> </li> </ul></template>requests.js
import { readonly, ref } from 'vue'
const list = ref([])
// Every component that imports this module sees the same requests. From outside they are// only readable, changes go through the functions below.export const requests = readonly(list)
export function add(thing) { if (!list.value.some((entry) => entry.id === thing.id)) list.value.push({ id: thing.id, name: thing.name })}
export function remove(id) { list.value = list.value.filter((entry) => entry.id !== id)}things.js
// A few things of the catalogexport const things = [ { id: 1, name: 'Drill', deposit: 20, availableFrom: null }, { id: 2, name: 'Tent for four', deposit: 50, availableFrom: '2026-11-12' }, { id: 5, name: 'Jigsaw', deposit: 20, availableFrom: null }, { id: 6, name: 'Projector', deposit: 80, availableFrom: null }, { id: 15, name: 'Circular saw', deposit: 40, availableFrom: null },]The Vue documentation names the catch. Every component that imports the module can change the state any way it likes, and in a large app nobody knows who changed what after a while. The module in the example therefore hands out the requests through readonly and only changes them in its functions add and remove. For apps that are rendered on a server, such a module doesn't work, because all requests to the server would share the same state there. An SPA like the one in this course isn't affected.
Getting by without a store
Section titled “Getting by without a store”A store pays off when many components in different places read and change the same state. If only one component needs a value, it stays local. If two siblings share it, the parent is enough. Data from the server is only a copy in the browser, and that copy can go out of date, because other members request things and the team records returns. A store can hold this copy, but it has to load it again whenever it may be outdated.
For the running case app, this leads to the following arrangement.
- The header, the catalog and the page of favorites need the favorites. They go into a store and stay in the browser's storage, as chapter 7.4 shows.
- The header, the guard and the form need the signed-in member. It goes into a store, but whether the login is valid is up to the server.
- Search, category and sort order stay in the address.
- The requests live on the association's server. A store holds the own requests as a copy for the header, the page of a thing and the overview.
In code
Section titled “In code”Work with the examples of this chapter. Read the code and write down what you expect before you change anything.
- In the first example, give
ItemListits own list of favorites again and remove the props. What does the header show now? - In the example with the module, try to change the requests in
AppHeaderwithrequests.value.push({ id: 1 }). What does the console report? - Replace
readonly(list)withlistin the module and try again. - Use the figure to place the three fields of the reservation form. Does anything change if someone should find a half-filled request again after a reload?
Exercises
Section titled “Exercises”The exercises build the favorites and the requests from the assignment. You'll find the project in the course repo in the folder exercises/07-state/where-state/en. Copy the folder, install the packages and start the tests.
npm installnpm testExercise Getting started
ItemList keeps the favorites itself, and the header always shows 0. Lift the favorites into App. ItemList gets them as the prop favorites and reports a click on "Favorite" with the event toggle-favorite. AppHeader gets the number as the prop favoriteCount.
Solution
<script setup>import { ref } from 'vue'
import AppHeader from '@/components/AppHeader.vue'import ItemList from '@/components/ItemList.vue'import RequestList from '@/components/RequestList.vue'
// Header and list both need the favorites, so they live in their common parentconst favorites = ref([])
function toggleFavorite(id) { favorites.value = favorites.value.includes(id) ? favorites.value.filter((entry) => entry !== id) : [...favorites.value, id]}</script>
<template> <AppHeader :favorite-count="favorites.length" /> <main> <h1>Catalog</h1> <RequestList /> <ItemList :favorites="favorites" @toggle-favorite="toggleFavorite" /> </main></template>ItemList no longer changes anything itself. It shows the favorites from the prop and reports every click with emit('toggle-favorite', item.id).
Exercise Going further
Create src/requests.js. It exports the requests as the ref requests holding an array, plus the functions add(item) and remove(id). The "Request" button in the list calls add, the header shows the number of requests, and RequestList shows them with a "Withdraw" button.
Solution
import { readonly, ref } from 'vue'
const list = ref([])
// Every component that imports this module sees the same requests. From outside they are// only readable, changes go through add and remove.export const requests = readonly(list)
export function add(item) { if (list.value.some((request) => request.id === item.id)) return list.value.push({ id: item.id, name: item.name })}
export function remove(id) { list.value = list.value.filter((request) => request.id !== id)}The solution already contains the challenge. For going further, export const requests = ref([]) with the two functions is enough. The components import what they need, AppHeader for example only requests.
Exercise Challenge
Other files should only be able to read requests, and changes only go through add and remove. Also, every thing appears in the list at most once.
Solution
import { readonly, ref } from 'vue'
const list = ref([])
// Every component that imports this module sees the same requests. From outside they are// only readable, changes go through add and remove.export const requests = readonly(list)
export function add(item) { if (list.value.some((request) => request.id === item.id)) return list.value.push({ id: item.id, name: item.name })}The module keeps the writable list to itself and only exports the view from readonly. An attempt to write from outside changes nothing, and Vue warns in the console.
Common mistakes
Section titled “Common mistakes”| Mistake | What helps |
|---|---|
| Two components show different favorites. | Each one keeps a copy of its own. Lift the state into the common parent. |
| Vue warns because a child component changes its prop. | The child reports an event, and the parent changes its state. |
| Many components change the state of a module, and nobody knows who changed what. | Hand out the state through readonly and change it only in functions of the module. |
| The filter lives in global state and is missing from a shared link. | Whatever should be shared and reloaded belongs in the address. |
| An AI assistant creates a store for every value, even for an open dialog. | Local state stays in the component. A store pays off for state that many parts of the app need. |
Self-check
Section titled “Self-check”Self-check
- In which order does the figure ask, and why does the address come first?
- What is prop drilling, and what helps against it?
- Why does the module hand out the requests through
readonly? - Why is the catalog in the browser only a copy?
Answers
- Address, server, one component, a few components, otherwise a store. Whatever someone wants to share or reload has to be in the address, no matter how many components need it.
- Props travel through components that don't need them, only to reach children further down.
provideandinject, a module or a store help. - That way the state only changes in the functions of the module, and a bug can be narrowed down to a few places.
- It belongs to the association's server. Other members and the team change it, and the app only notices on the next load.
Assignment
The arrangement is settled. For the login and the requests, though, the volunteer wants more than a module. "I'd like to see in the developer tools what changes when, and to prepare the state easily in tests." That is what Pinia offers, the library for stores that the next chapter introduces.
Text and figures are licensed under CC BY-SA 4.0, the code under the MIT License and the course data under CC0 1.0. The figures use the font Kalam under the SIL Open Font License 1.1. The sources are on GitHub.