Course outline

Vue / Intermediate: feature state and effect ownership / Lesson 11

Composable contracts and reusable stateful logic

Extract reusable behavior without losing source reactivity, ownership, or cleanup.

About 4 min readContent reviewed
On this page

Lesson goals

  • Define inputs as values, refs, or getters intentionally.
  • Return a small reactive interface with clear lifetime.

Extract behavior rather than arbitrary lines

A composable is a function that uses Vue’s Composition API to encapsulate reusable stateful logic. It differs from a pure utility because it can create reactive state, effects, and lifecycle relationships. Extract a coherent capability with named inputs, output state, operations, and disposal requirements. A long component is not automatically improved by moving its entire body into one opaque function. Keep domain validation, rendering, and external service boundaries understandable. Reuse should reduce duplicated decisions while preserving a clear explanation of who owns the resulting state and when it should reset.

Normalize inputs inside the tracked computation

A caller may pass a plain value, ref, or getter. If the composable promises to follow future changes, reading the input only once during initialization breaks that promise. toValue normalizes supported inputs and calls getters, but the read must occur inside a tracked computation or effect when updates are required. The example computes a normalized query from the current source. toValue is supported in the course’s Vue 3.5 baseline; do not assume it exists in every older release. The returned computed ref represents derived state and should not be mutated by consumers.

javascript A composable with a live source contract
import { computed, toValue } from "vue"

export function useNormalizedQuery(source) {
  const normalized = computed(() => String(toValue(source) ?? "").trim())
  return { normalized }
}

Requires Vue and a caller that supplies a supported value, ref, or getter. If a reactive source changes from spaced text to new text, the computed read follows it. The module does not perform I/O or claim a framework runtime test.

Pass a live prop relationship explicitly

The parent of this component owns query. Passing a getter into the composable preserves the property access rather than handing it one primitive snapshot. This pattern also works when compiler-assisted prop destructuring is not involved, making the boundary easier to reason about. The component merely displays the normalized view; it does not mutate the parent or issue a request. If the normalized value later becomes a request input, add explicit async ownership and invalidation at that boundary. A reusable normalization function should not quietly create network traffic because an input was read.

vue A component preserves prop reactivity through a getter
<script setup>
import { useNormalizedQuery } from "./composables/useNormalizedQuery.js"
const props = defineProps({ query: { type: String, required: true } })
const { normalized } = useNormalizedQuery(() => props.query)
</script>

<template>
  <p>Normalized query: {{ normalized }}</p>
</template>

Requires the previous composable and a parent-supplied query prop. Input " notes " produces the displayed normalized text notes. The SFC and compiler interaction remain unexecuted and non-runnable here.

Return state and actions with predictable ownership

A common composable return shape is a plain object containing refs and functions, allowing callers to destructure while retaining ref identity. Returning a reactive object can have different destructuring behavior and should be documented. Decide whether callers may mutate returned refs or must use operations that preserve invariants. Per-call state normally gives each consumer an independent instance; module-level state intentionally shares it and can become a cross-request problem during SSR. Do not hide singleton behavior inside a helper whose name suggests local state. Make reset, cache, and persistence semantics part of the public contract.

Test pure rules and lifecycle integration separately

A composable with only computed state can be tested around source changes under a compatible Vue test environment. A composable using lifecycle hooks must be invoked in an appropriate component context and tested through mount and unmount. External services should be injected so tests can control outcomes without real network access. Keep cleanup, error behavior, and repeated invocation visible. Pure string or collection utilities can be validated independently under Node, while that result does not prove Vue dependency tracking. The test boundary should match the claim and report the untested integration assumptions honestly.

Common errors and fixes

A composable follows only its initial input

Why it happens
The source was normalized once outside tracked execution.
How to fix it
Read the source inside computed or the relevant effect.
Prevent it next time
Test input changes after initialization.

Two consumers unexpectedly share a draft

Why it happens
State was allocated at module scope.
How to fix it
Create per-call state or document intentional shared ownership.
Prevent it next time
Review composable scope and SSR behavior.

A lifecycle composable is called outside component setup

Why it happens
The helper’s context requirement was hidden.
How to fix it
Invoke it under the correct owner or redesign explicit disposal.
Prevent it next time
Document setup and cleanup requirements.

Check your understanding

1. Why return a plain object of refs instead of copied primitive values?

Show worked solution

Refs preserve the live state interface when the caller destructures the result. Copied primitives preserve only one moment. Still document mutation authority and lifetime, because retaining reactivity alone does not define correct ownership.

2. A composable starts a request from a computed getter. What should change?

Show worked solution

Keep derivation pure and move request work into an explicit effect with a defined source, cancellation, error state, and owner. The returned value should not hide repeated external side effects whenever a consumer reads it.

Key takeaways

  • Composable inputs need a snapshot or live-source contract.
  • State scope and return shape determine ownership behavior.
  • Effects and lifecycle requirements must remain visible.

Official references