Skip to content

How to Handle API Calls in Pinia with The Elm Pattern

Published: at 

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:

  1. Deterministic: Given the same inputs, it always returns the same output.
  2. 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:

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:

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.

  1. Keep the update function pure.
  2. Move side effects into separate functions that receive a dispatch function.
  3. 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:

This structure makes it easy to:

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.

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:

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.

  1. Define your model and messages.
  2. Keep the update function pure.
  3. Implement effects as separate functions that take a dispatch function.
  4. Connect them inside the store.

This structure keeps your Pinia stores predictable, testable, and easy to extend to any type of side effect.

Stay Updated!

Subscribe to my newsletter for more TypeScript, Vue, and web dev insights directly in your inbox.

  • Background information about the articles
  • Weekly Summary of all the interesting blog posts that I read
  • Small tips and trick
Subscribe Now