Understanding Pure Functions and Side Effects
Before diving into the pattern, it’s important to understand the foundational concepts of functional programming that make this approach powerful.
What Is a Pure Function?
A pure function is a function that satisfies two key properties:
- Deterministic: Given the same inputs, it always returns the same output.
- No side effects: It does not interact with anything outside its scope.
Here’s a simple example:
// Pure function - always predictable
function add(a: number, b: number): number {
return a + b
}
add(2, 3) // Always returns 5
add(2, 3) // Always returns 5
This function is pure because:
- It only depends on its inputs (
aandb) - It always produces the same result for the same inputs
- It doesn’t modify any external state
- It doesn’t perform any I/O operations
What Is a Side Effect?
A side effect is any operation that interacts with the outside world or modifies state beyond the function’s return value.
Common side effects include:
// Side effect: Network request
function fetchUser(id: number) {
return fetch(`/api/users/${id}`) // Network I/O
}
// Side effect: Modifying external state
let count = 0
function increment() {
count++ // Mutates external variable
}
// Side effect: Writing to storage
function saveUser(user: User) {
localStorage.setItem('user', JSON.stringify(user)) // I/O operation
}
// Side effect: Logging
function calculate(x: number) {
console.log('Calculating...') // I/O operation
return x * 2
}
None of these are pure functions because they interact with something beyond their inputs and outputs.
Why Does This Matter?
Pure functions are easier to:
- Test: No need to mock APIs, databases, or global state
- Reason about: The function’s behavior is completely determined by its inputs
- Debug: No hidden dependencies or unexpected state changes
- Reuse: Work anywhere without environmental setup
However, real applications need side effects. You can’t build useful software without API calls, database writes, or user interactions.
The key insight from functional programming is not to eliminate side effects, but to separate them from your business logic.
Why Side Effects Are a Problem
A pure function only depends on its inputs and always returns the same output. If you include an API call or any asynchronous operation inside it, the function becomes unpredictable and hard to test.
Example:
export function update(model, msg) {
if (msg.type === 'FETCH_POKEMON') {
fetch('https://pokeapi.co/api/v2/pokemon/pikachu')
return { ...model, isLoading: true }
}
}
This mixes logic with side effects. The function now depends on the network and the API structure, making it complex to test and reason about.
The Solution: Separate Logic and Effects
The Elm Architecture provides a simple way to handle side effects correctly.
- Keep the update function pure.
- Move side effects into separate functions that receive a dispatch function.
- Use the store as the bridge between both layers.
This separation keeps your business logic independent of the framework and easier to verify.
File Organization
Before diving into the code, here’s how we organize the files for a Pinia store using the Elm pattern:
src/
└── stores/
└── pokemon/
├── pokemonModel.ts # Types and initial state
├── pokemonUpdate.ts # Pure update function
├── pokemonEffects.ts # Side effects (API calls)
└── pokemon.ts # Pinia store (connects everything)
Each file has a clear, single responsibility:
pokemonModel.ts: Defines the state shape and message typespokemonUpdate.ts: Contains pure logic for state transitionspokemonEffects.ts: Handles side effects like API callspokemon.ts: The Pinia store that wires everything together
This structure makes it easy to:
- Find and modify specific logic
- Test each piece independently
- Reuse the update logic in different contexts
- Add new effects without touching business logic
Example: Fetching Data from the Pokémon API
This example demonstrates how to handle an API call using this pattern.
pokemonModel.ts
The model defines the structure of the state and the possible messages that can change it.
export type PokemonModel = {
isLoading: boolean
pokemon: string | null
error: string | null
}
export const initialModel: PokemonModel = {
isLoading: false,
pokemon: null,
error: null,
}
export type PokemonMsg =
| { type: 'FETCH_REQUEST'; name: string }
| { type: 'FETCH_SUCCESS'; pokemon: string }
| { type: 'FETCH_FAILURE'; error: string }
pokemonUpdate.ts
The update function handles all state transitions in a pure way.
import type { PokemonModel, PokemonMsg } from './pokemonModel'
export function update(model: PokemonModel, msg: PokemonMsg): PokemonModel {
switch (msg.type) {
case 'FETCH_REQUEST':
return { ...model, isLoading: true, error: null }
case 'FETCH_SUCCESS':
return { ...model, isLoading: false, pokemon: msg.pokemon }
case 'FETCH_FAILURE':
return { ...model, isLoading: false, error: msg.error }
default:
return model
}
}
This function has no side effects. It only describes how the state changes in response to a message.
pokemonEffects.ts
This file performs the network request and communicates back through the dispatch function.
import type { PokemonMsg } from './pokemonModel'
export async function fetchPokemon(name: string, dispatch: (m: PokemonMsg) => void) {
dispatch({ type: 'FETCH_REQUEST', name })
try {
const res = await fetch(`https://pokeapi.co/api/v2/pokemon/${name}`)
if (!res.ok) throw new Error('Not found')
const data = await res.json()
dispatch({ type: 'FETCH_SUCCESS', pokemon: data.name })
} catch (e: any) {
dispatch({ type: 'FETCH_FAILURE', error: e.message })
}
}
This function does not depend on Pinia or Vue. It simply performs the side effect and dispatches messages based on the result.
pokemon.ts
The Pinia store connects the pure logic and the side effect layer.
import { defineStore } from 'pinia'
import { ref, readonly } from 'vue'
import { initialModel, type PokemonModel, type PokemonMsg } from './pokemonModel'
import { update } from './pokemonUpdate'
import { fetchPokemon } from './pokemonEffects'
export const usePokemonStore = defineStore('pokemon', () => {
const model = ref<PokemonModel>(initialModel)
function dispatch(msg: PokemonMsg) {
model.value = update(model.value, msg)
}
async function load(name: string) {
await fetchPokemon(name, dispatch)
}
return {
state: readonly(model),
load,
}
})
The store contains no direct logic for handling API responses. It only coordinates updates and side effects.
Usage in a Component
<script setup lang="ts">
import { ref } from 'vue'
import { usePokemonStore } from '@/stores/pokemon'
const store = usePokemonStore()
const name = ref('pikachu')
function fetchIt() {
store.load(name.value)
}
</script>
<template>
<div>
<input v-model="name" placeholder="Enter Pokémon name" />
<button @click="fetchIt">Search</button>
<p v-if="store.state.isLoading">Loading...</p>
<p v-else-if="store.state.error">Error: {{ store.state.error }}</p>
<p v-else-if="store.state.pokemon">Found: {{ store.state.pokemon }}</p>
</div>
</template>
The component only interacts with the public API of the store. It does not mutate the state directly.
Why This Approach Works
Separating logic and effects provides several benefits.
- The update function is pure and easy to test.
- The side effect functions are independent and reusable.
- The store focuses only on coordination.
- The overall data flow remains predictable and maintainable.
This method is especially effective in projects where you want full control over how and when side effects are executed.
Other Side Effects You Can Handle with This Pattern
This pattern is not limited to API requests. You can manage any kind of asynchronous or external operation the same way.
Examples include:
- Writing to or reading from
localStorageorIndexedDB - Sending analytics or telemetry events
- Performing authentication or token refresh logic
- Communicating with WebSockets or event streams
- Scheduling background tasks with
setTimeoutorrequestAnimationFrame - Reading files or using browser APIs such as the Clipboard or File System
By using the same structure, you can keep these effects organized and testable. Each effect becomes an independent unit that transforms external data into messages for your update function.
Summary
If you only need caching or background synchronization, use a specialized library such as pinia-colada, TanStack Vue Query, or RStore. If you need to stay within Pinia and still maintain a functional structure, this approach is effective.
- Define your model and messages.
- Keep the update function pure.
- Implement effects as separate functions that take a dispatch function.
- Connect them inside the store.
This structure keeps your Pinia stores predictable, testable, and easy to extend to any type of side effect.