Skip to content

Migrating from Apollo Client

Step-by-step guide to migrating from Apollo Client to Verity

This guide helps you transition from Apollo Client to Verity while optionally keeping your GraphQL backend.


Why Migrate?

When Does Migration Make Sense?

Consider Verity if you're experiencing:

  • 🔄 GraphQL subscription complexity
  • 🐛 Cache normalization bugs
  • 📱 Need simpler multi-client sync
  • 🤔 Optimistic update rollback issues
  • 💰 Apollo Client bundle size concerns
  • ✅ Simpler mental model (no normalized cache)
  • ✅ Server-authored invalidation
  • ✅ Built-in SSE for real-time (simpler than subscriptions)
  • ✅ Smaller bundle size
  • ✅ Framework flexibility
  • ✅ No optimistic updates (honest UX)
  • ❌ Normalized cache (by design)
  • ❌ Fragment composition
  • ❌ Apollo DevTools
  • ❌ Automatic cache updates from mutations
  • ❌ Large Apollo ecosystem

You Can Keep GraphQL!

Verity works with any backend transport. Your fetchers can call GraphQL queries. You just won't use Apollo's cache—Verity's cache works differently.


Conceptual Mapping

Queries → Collections & Types

import { useQuery, gql } from '@apollo/client'

const GET_TODOS = gql`
  query GetTodos {
    todos {
      id
      title
      completed
    }
  }
`

function TodoList() {
  const { data, loading } = useQuery(GET_TODOS)

  return (
    <ul>
      {data?.todos.map(todo => <li key={todo.id}>{todo.title}</li>)}
    </ul>
  )
}
import { useCollection } from 'verity-dl/adapters/react'

// In registry setup
registry.registerCollection('todos', {
  fetch: async () => {
    const res = await fetch('/graphql', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        query: `
          query GetTodos {
            todos {
              id
              title
              completed
            }
          }
        `
      })
    })
    const { data } = await res.json()
    return { items: data.todos }
  }
})

// In component
function TodoList() {
  const todos = useCollection('todos')

  return (
    <ul>
      {todos.state.items.map(todo => <li key={todo.id}>{todo.title}</li>)}
    </ul>
  )
}
// Or switch to REST (simpler)
registry.registerCollection('todos', {
  fetch: () => fetch('/api/todos').then(r => r.json())
})

GraphQL is Optional

Migration is a good time to evaluate if you still need GraphQL. If you're not using fragments, unions, or complex nested queries, REST might be simpler.

Fragments → Levels

const TODO_SUMMARY = gql`
  fragment TodoSummary on Todo {
    id
    title
    completed
  }
`

const TODO_DETAIL = gql`
  fragment TodoDetail on Todo {
    ...TodoSummary
    description
    assignee {
      name
      avatar
    }
    comments {
      text
      author
    }
  }
`
registry.registerType('todo', {
  // Default level (summary)
  fetch: async ({ id }) => {
    const res = await fetch(`/api/todos/${id}`)
    return res.json()
  },

  levels: {
    detailed: {
      fetch: async ({ id }) => {
        const res = await fetch(`/api/todos/${id}?detailed=true`)
        return res.json()
      },
      checkIfExists: (obj) => 
        'description' in obj && 'assignee' in obj && 'comments' in obj,
      levelConversionMap: {
        null: true  // detailed includes summary
      }
    }
  }
})

Simpler Model

Verity: Explicit levels. Apollo: Fragment composition + normalized cache.

Mutations → Directives

const [updateTodo] = useMutation(gql`
  mutation UpdateTodo($id: ID!, $completed: Boolean!) {
    updateTodo(id: $id, completed: $completed) {
      id
      completed
    }
  }
`, {
  // Option 1: Optimistic response
  optimisticResponse: {
    updateTodo: {
      __typename: 'Todo',
      id: todoId,
      completed: !todo.completed
    }
  },
  // Option 2: Update cache manually
  update(cache, { data: { updateTodo } }) {
    cache.modify({
      id: cache.identify(updateTodo),
      fields: {
        completed() { return updateTodo.completed }
      }
    })
  },
  // Option 3: Refetch queries
  refetchQueries: [{ query: GET_TODOS }]
})

Problems:

  • Three different cache update strategies
  • Optimistic updates require rollback logic
  • Manual cache updates are error-prone
  • Refetching is inefficient
async function updateTodo(id, completed) {
  const res = await fetch(`/api/todos/${id}`, {
    method: 'PUT',
    headers: {
      'Content-Type': 'application/json',
      'X-Client-ID': registry.clientId
    },
    body: JSON.stringify({ completed })
  })

  const { directives } = await res.json()
  // Server returned:
  // [
  //   { op: 'refresh_item', name: 'todo', id: 42 },
  //   { op: 'refresh_collection', name: 'todos' }
  // ]

  await registry.applyDirectives(directives)
}

Benefits:

  • One strategy: server decides
  • No optimistic updates
  • No manual cache manipulation
  • Minimal refetching (only what changed)

Subscriptions → SSE

const { data } = useSubscription(gql`
  subscription OnTodoUpdated {
    todoUpdated {
      id
      title
      completed
    }
  }
`)

// Requires:
// - WebSocket transport
// - Subscription server setup
// - Complex reconnection logic
// - Manual cache updates
// Setup (once)
const registry = Verity.createRegistry({
  sse: {
    url: '/api/events',
    audience: 'global'
  }
})

// Server-side (Python/Flask example)
@app.put('/api/todos/<int:id>')
def update_todo(id):
    # ... update database ...

    directives = [
        { 'op': 'refresh_item', 'name': 'todo', 'id': id },
        { 'op': 'refresh_collection', 'name': 'todos' }
    ]

    emit_directives(directives)
    return { 'directives': directives }

# SSE endpoint
@app.get('/api/events')
def sse_stream():
    # Stream directives to all clients
    # (Much simpler than WebSocket subscriptions)

Benefits:

  • Simpler protocol (HTTP)
  • Auto-reconnection built-in
  • Works through most proxies
  • Directives are transport-agnostic

Step-by-Step Migration

Step 1: Audit Your Apollo Usage

Understand What You're Using

Before migrating, audit your Apollo features:

Check if you're using:

  • Normalized cache
  • Fragment composition
  • Optimistic updates
  • Subscriptions
  • Local state management
  • Cache policies
  • Type policies

If you're using many of these features, migration will be more work. Consider:

  • Gradual migration (page by page)
  • Keeping Apollo for complex queries
  • Simplifying data patterns first

If you're mostly doing simple queries/mutations, migration is straightforward:

  • Most queries → registerCollection or registerType
  • Most mutations → directive-based
  • Subscriptions → SSE

Step 2: Install Verity

# Keep both during migration
npm install verity-dl

# Or via CDN
<head>
  <script src="https://cdn.jsdelivr.net/npm/verity-dl@latest/verity/shared/static/lib/core.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/verity-dl@latest/verity/shared/static/adapters/react.min.js"></script>
</head>

Step 3: Create Registry

// src/verity.js
import { createRegistry } from 'verity-dl'

export const registry = createRegistry({
  sse: {
    url: '/api/events',
    audience: 'global'
  }
})

Step 4: Convert Queries

Before (Apollo):

const GET_TODOS = gql`
  query GetTodos {
    todos {
      id
      title
      completed
    }
  }
`

const { data, loading } = useQuery(GET_TODOS)

After (Verity + GraphQL):

// Registry setup
registry.registerCollection('todos', {
  fetch: async () => {
    const res = await fetch('/graphql', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        query: `query GetTodos { todos { id title completed } }`
      })
    })
    const { data } = await res.json()
    return { items: data.todos }
  }
})

// Component
const todos = useCollection('todos')

Or switch to REST:

registry.registerCollection('todos', {
  fetch: () => fetch('/api/todos').then(r => r.json())
})

Before (Apollo):

const GET_TODOS = gql`
  query GetTodos($status: String) {
    todos(status: $status) {
      id
      title
    }
  }
`

const { data } = useQuery(GET_TODOS, {
  variables: { status: 'active' }
})

After (Verity):

registry.registerCollection('todos', {
  fetch: async (params = {}) => {
    const url = new URL('/api/todos', location.origin)
    if (params.status) url.searchParams.set('status', params.status)
    return fetch(url).then(r => r.json())
  }
})

const todos = useCollection('todos', { status: 'active' })

Before (Apollo):

const GET_TODO = gql`
  query GetTodo($id: ID!) {
    todo(id: $id) {
      id
      title
      description
      assignee { name }
    }
  }
`

const { data } = useQuery(GET_TODO, {
  variables: { id: todoId }
})

After (Verity):

registry.registerType('todo', {
  fetch: ({ id }) => fetch(`/api/todos/${id}`).then(r => r.json())
})

const todo = useItem('todo', todoId)

Step 5: Convert Mutations

const [updateTodo, { loading }] = useMutation(gql`
  mutation UpdateTodo($id: ID!, $data: TodoInput!) {
    updateTodo(id: $id, data: $data) {
      id
      title
      completed
    }
  }
`, {
  optimisticResponse: {
    updateTodo: {
      __typename: 'Todo',
      id: todoId,
      ...data
    }
  },
  update(cache, { data: { updateTodo } }) {
    cache.modify({
      fields: {
        todos(existing = []) {
          return existing.map(todo =>
            todo.id === updateTodo.id ? updateTodo : todo
          )
        }
      }
    })
  }
})

const handleUpdate = () => {
  updateTodo({
    variables: {
      id: todoId,
      data: { completed: true }
    }
  })
}
const [isUpdating, setIsUpdating] = useState(false)

const handleUpdate = async () => {
  setIsUpdating(true)

  try {
    const res = await fetch(`/api/todos/${todoId}`, {
      method: 'PUT',
      headers: {
        'Content-Type': 'application/json',
        'X-Client-ID': registry.clientId
      },
      body: JSON.stringify({ completed: true })
    })

    const { directives } = await res.json()
    await registry.applyDirectives(directives)
  } finally {
    setIsUpdating(false)
  }
}

Step 6: Replace Subscriptions with SSE

Client:

import { useSubscription } from '@apollo/client'

const TODO_UPDATED = gql`
  subscription OnTodoUpdated {
    todoUpdated {
      id
      title
      completed
    }
  }
`

function TodoList() {
  const { data: subscriptionData } = useSubscription(TODO_UPDATED)

  // Manually update cache when subscription fires
  useEffect(() => {
    if (subscriptionData) {
      // Complex cache update logic
    }
  }, [subscriptionData])
}

Server (requires WebSocket server):

const { PubSub } = require('graphql-subscriptions')
const pubsub = new PubSub()

const resolvers = {
  Mutation: {
    updateTodo: async (_, { id, data }) => {
      const todo = await updateTodoInDb(id, data)
      pubsub.publish('TODO_UPDATED', { todoUpdated: todo })
      return todo
    }
  },
  Subscription: {
    todoUpdated: {
      subscribe: () => pubsub.asyncIterator(['TODO_UPDATED'])
    }
  }
}

Client (auto-configured):

// Just configure once in registry
const registry = Verity.createRegistry({
  sse: { url: '/api/events' }
})

// That's it! SSE handles all updates automatically

Server (much simpler):

from flask import Response
import json

subscribers = []

def emit_directives(directives):
    payload = json.dumps({
        'type': 'directives',
        'directives': directives
    })
    for subscriber in subscribers:
        subscriber.put(payload)

@app.get('/api/events')
def sse_stream():
    import queue
    q = queue.Queue()
    subscribers.append(q)

    def generate():
        try:
            while True:
                msg = q.get(timeout=30)
                yield f"data: {msg}\n\n"
        except queue.Empty:
            yield ": keepalive\n\n"
        finally:
            subscribers.remove(q)

    return Response(generate(), mimetype='text/event-stream')

@app.put('/api/todos/<int:id>')
def update_todo(id):
    # Update database
    todo = update_in_db(id, request.json)

    # Emit directives
    directives = [
        { 'op': 'refresh_item', 'name': 'todo', 'id': id },
        { 'op': 'refresh_collection', 'name': 'todos' }
    ]
    emit_directives(directives)

    return { 'todo': todo.to_dict(), 'directives': directives }

Simplicity Win

No WebSocket server, no subscription resolvers, no manual cache updates. Just emit directives.

Step 7: Remove Normalized Cache Dependencies

Apollo's normalized cache is powerful but complex. Verity uses a simpler model.

// Reading from cache
const todo = client.readFragment({
  id: cache.identify({ __typename: 'Todo', id: todoId }),
  fragment: TODO_FRAGMENT
})

// Writing to cache
client.writeFragment({
  id: cache.identify({ __typename: 'Todo', id: todoId }),
  fragment: TODO_FRAGMENT,
  data: { ...todo, completed: true }
})

// Cache policies
const cache = new InMemoryCache({
  typePolicies: {
    Todo: {
      keyFields: ['id'],
      fields: {
        title: {
          merge(existing, incoming) {
            return incoming
          }
        }
      }
    }
  }
})
// Just use the API
const todo = registry.item('todo', todoId)

// No manual cache writes
// Server directives handle updates

// No type policies needed
// Collections and types are explicitly registered

Trade-Off

Apollo: Automatic cache normalization, complex. Verity: Explicit cache management, simple.


Common Patterns

Pattern: Polling

const { data } = useQuery(GET_TODOS, {
  pollInterval: 5000  // Poll every 5 seconds
})
// Use SSE instead of polling
const registry = Verity.createRegistry({
  sse: { url: '/api/events' }
})

// Or if you must poll
useEffect(() => {
  const interval = setInterval(() => {
    todos.refresh()
  }, 5000)
  return () => clearInterval(interval)
}, [])

Better Approach

SSE is more efficient than polling. Only send updates when something changes.

Pattern: Cache-First vs Network-First

const { data } = useQuery(GET_TODOS, {
  fetchPolicy: 'cache-first'  // or 'network-only'
})
// Always cache-first by default
const todos = useCollection('todos')

// Force refetch
const todos = useCollection('todos', {}, { force: true })

// Or refresh manually
todos.refresh()

Pattern: Local State

const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        isLoggedIn: {
          read() {
            return localStorage.getItem('token') !== null
          }
        }
      }
    }
  }
})

const { data } = useQuery(gql`
  query GetAuthState {
    isLoggedIn @client
  }
`)
// Use React state for local UI state
const [isLoggedIn, setIsLoggedIn] = useState(
  localStorage.getItem('token') !== null
)

// Verity is for server truth-state only
// Local view-state stays in your framework

Clearer Separation

Verity enforces truth-state (server) vs view-state (client) boundary.


Bundle Size Comparison

Library Minified + Gzipped
Apollo Client + deps ~32 KB
Verity core ~8 KB
Verity + Alpine adapter ~10 KB
Verity + React adapter ~12 KB

70% Smaller

Verity is significantly smaller than Apollo Client.


Checklist

Migration Checklist

Preparation: - [ ] Audit Apollo features in use - [ ] Identify queries, mutations, subscriptions - [ ] Document fragments and cache policies

Setup: - [ ] Install Verity - [ ] Create registry - [ ] Set up SSE endpoint (if using real-time)

Migration: - [ ] Convert queries to collections/types - [ ] Remove optimistic updates - [ ] Convert mutations to directive-based - [ ] Replace subscriptions with SSE - [ ] Remove cache manipulation code

Backend: - [ ] Add directive returns to mutations - [ ] Implement SSE broadcasting - [ ] (Optional) Simplify from GraphQL to REST

Cleanup: - [ ] Remove Apollo Client - [ ] Remove GraphQL subscriptions (if using SSE) - [ ] Update tests


Next Steps