tRPC Beispiele

tRPC Beispiele einschließlich typsicherer End-to-End-APIs, Prozeduren, Middleware, Authentifizierung und Client-Server-Integrationsmuster

💻 tRPC Grundkonfiguration typescript

🟢 simple ⭐⭐

Grundlegende tRPC Server- und Client-Konfiguration mit einfachen Prozeduren und Typsicherheit

⏱️ 30 min 🏷️ trpc, typescript, api, type-safe
Prerequisites: TypeScript basics, Express.js, REST API concepts
// tRPC Basic Setup Example
// Server-side setup and client integration

import { initTRPC } from '@trpc/server';
import { createExpressMiddleware } from '@trpc/server/adapters/express';
import express from 'express';
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';

// 1. Initialize tRPC
const t = initTRPC.create();

// 2. Define context type
export type Context = {
  userId?: string;
  user?: {
    id: string;
    name: string;
  };
};

// 3. Create router
export const appRouter = t.router({
  // Simple query procedure
  hello: t.procedure
    .query(() => {
      return { greeting: 'Hello tRPC!' };
    }),

  // Query with input validation
  getUser: t.procedure
    .input((val: unknown) => {
      if (typeof val === 'string' || typeof val === 'number') {
        return String(val);
      }
      throw new Error(`Invalid input: expected string or number`);
    })
    .query(({ input }) => {
      return {
        id: input,
        name: `User ${input}`,
        email: `user${input}@example.com`,
        createdAt: new Date().toISOString()
      };
    }),

  // Simple mutation
  createUser: t.procedure
    .input((val: unknown) => {
      if (typeof val !== 'object' || val === null) {
        throw new Error('Expected object');
      }
      const { name, email } = val as { name?: unknown; email?: unknown };

      if (typeof name !== 'string' || typeof email !== 'string') {
        throw new Error('Name and email must be strings');
      }

      return { name, email };
    })
    .mutation(({ input }) => {
      // Simulate database save
      const user = {
        id: Math.random().toString(36).substr(2, 9),
        name: input.name,
        email: input.email,
        createdAt: new Date().toISOString()
      };

      return { success: true, user };
    }),

  // Query with context
  me: t.procedure
    .query(({ ctx }) => {
      return ctx.user || null;
    }),

  // Subscription example
  onHello: t.procedure
    .subscription(() => {
      return observable((emit) => {
        const timer = setInterval(() => {
          emit.next({ greeting: 'Hello from subscription!' });
        }, 1000);

        return () => {
          clearInterval(timer);
        };
      });
    }),
});

// Export router type
export type AppRouter = typeof appRouter;

// 4. Express server setup
const app = express();

app.use('/trpc', createExpressMiddleware({
  router: appRouter,
  createContext: ({ req }) => {
    // Simulate authentication
    const userId = req.headers['x-user-id'] as string;

    return {
      userId,
      user: userId ? {
        id: userId,
        name: `User ${userId}`
      } : undefined,
    };
  },
}));

const server = app.listen(3000, () => {
  console.log('tRPC server running on port 3000');
});

// 5. Client setup (can be in separate file)
const trpc = createTRPCProxyClient<AppRouter>({
  links: [
    httpBatchLink({
      url: 'http://localhost:3000/trpc',
      headers: {
        'x-user-id': 'user123', // Example auth header
      },
    }),
  ],
});

// 6. Client usage examples
async function clientExamples() {
  // Query
  const hello = await trpc.hello.query();
  console.log(hello); // { greeting: 'Hello tRPC!' }

  // Query with input
  const user = await trpc.getUser.query('123');
  console.log(user); // { id: '123', name: 'User 123', email: '[email protected]', ... }

  // Mutation
  const newUser = await trpc.createUser.mutate({
    name: 'John Doe',
    email: '[email protected]'
  });
  console.log(newUser); // { success: true, user: { id: 'abc', name: 'John Doe', ... } }

  // Query with context
  const me = await trpc.me.query();
  console.log(me); // { id: 'user123', name: 'User user123' }

  // Subscription
  const subscription = trpc.onHello.subscribe(undefined, {
    onData(data) {
      console.log(data); // { greeting: 'Hello from subscription!' } every second
    },
    onError(err) {
      console.error('Subscription error:', err);
    },
  });

  // Clean up subscription after 5 seconds
  setTimeout(() => {
    subscription.unsubscribe();
  }, 5000);
}

// Execute client examples
clientExamples().catch(console.error);

// 7. Error handling example
const errorRouter = t.router({
  mightFail: t.procedure
    .query(() => {
      // Simulate potential error
      if (Math.random() > 0.5) {
        throw new Error('Random failure occurred!');
      }
      return { success: true };
    }),
});

// 8. Combined router with error handling
export const combinedRouter = t.router({
  app: appRouter,
  error: errorRouter,
});

export type CombinedRouter = typeof combinedRouter;

// 9. Async procedure example
const asyncRouter = t.router({
  fetchData: t.procedure
    .input((val: unknown) => {
      if (typeof val !== 'string') {
        throw new Error('Expected string URL');
      }
      return val;
    })
    .query(async ({ input }) => {
      // Simulate async operation (e.g., database query or API call)
      await new Promise(resolve => setTimeout(resolve, 1000));

      return {
        url: input,
        data: `Data fetched from ${input}`,
        timestamp: new Date().toISOString()
      };
    }),

  processItem: t.procedure
    .input((val: unknown) => {
      if (typeof val !== 'object' || val === null) {
        throw new Error('Expected object');
      }
      return val as { id: string; data: any };
    })
    .mutation(async ({ input }) => {
      // Simulate async processing
      await new Promise(resolve => setTimeout(resolve, 500));

      return {
        processed: true,
        id: input.id,
        result: `Processed: ${JSON.stringify(input.data)}`,
        processedAt: new Date().toISOString()
      };
    }),
});

export type AsyncRouter = typeof asyncRouter;

💻 tRPC Middleware und Authentifizierung typescript

🟡 intermediate ⭐⭐⭐⭐

Erweiterte tRPC Muster einschließlich Middleware, Authentifizierung, Autorisierung und Fehlerbehandlung

⏱️ 45 min 🏷️ trpc, middleware, auth, security, typescript
Prerequisites: tRPC basics, TypeScript, JWT, Express.js, Security concepts
// tRPC Middleware and Authentication Example

import { initTRPC, TRPCError } from '@trpc/server';
import { createExpressMiddleware } from '@trpc/server/adapters/express';
import express from 'express';
import jwt from 'jsonwebtoken';

// 1. Initialize tRPC
const t = initTRPC.create();

// 2. Define database/user types
interface User {
  id: string;
  email: string;
  name: string;
  role: 'admin' | 'user';
}

interface Database {
  users: User[];
  posts: Array<{
    id: string;
    title: string;
    content: string;
    authorId: string;
    published: boolean;
    createdAt: Date;
  }>;
}

// Mock database
const db: Database = {
  users: [
    { id: '1', email: '[email protected]', name: 'Admin User', role: 'admin' },
    { id: '2', email: '[email protected]', name: 'Regular User', role: 'user' },
  ],
  posts: [
    { id: '1', title: 'First Post', content: 'Hello World', authorId: '1', published: true, createdAt: new Date() },
    { id: '2', title: 'Draft Post', content: 'This is a draft', authorId: '2', published: false, createdAt: new Date() },
  ],
};

// 3. Define context type
export interface Context {
  user?: User;
  req: express.Request;
}

// 4. Authentication middleware
const isAuthed = t.middleware(async ({ next, ctx }) => {
  const authHeader = ctx.req.headers.authorization;

  if (!authHeader?.startsWith('Bearer ')) {
    throw new TRPCError({ code: 'UNAUTHORIZED' });
  }

  const token = authHeader.split(' ')[1];

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET || 'secret-key') as { userId: string };
    const user = db.users.find(u => u.id === decoded.userId);

    if (!user) {
      throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not found' });
    }

    return next({
      ctx: {
        ...ctx,
        user,
      },
    });
  } catch (error) {
    throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid token' });
  }
});

// 5. Role-based authorization middleware
const hasRole = (role: 'admin' | 'user') =>
  t.middleware(async ({ next, ctx }) => {
    if (!ctx.user) {
      throw new TRPCError({ code: 'UNAUTHORIZED' });
    }

    if (ctx.user.role !== role && ctx.user.role !== 'admin') {
      throw new TRPCError({
        code: 'FORBIDDEN',
        message: `Requires ${role} role or higher`
      });
    }

    return next({
      ctx: {
        ...ctx,
      },
    });
  });

// 6. Logging middleware
const logger = t.middleware(async ({ next, path, type, input }) => {
  const start = Date.now();
  const result = await next({ ctx: {} });
  const duration = Date.now() - start;

  console.log(`[${new Date().toISOString()}] ${type.toUpperCase()} ${path}: ${duration}ms`);

  return result;
});

// 7. Error handling middleware
const errorHandler = t.middleware(async ({ next }) => {
  try {
    return await next();
  } catch (error) {
    console.error('tRPC Error:', error);
    throw error;
  }
});

// 8. Rate limiting middleware
const rateLimit = t.middleware(async ({ next, ctx }) => {
  const clientIP = ctx.req.ip;
  const currentTime = Date.now();

  // In production, you'd use Redis or a proper rate limiting store
  const requestCounts = new Map<string, { count: number; resetTime: number }>();

  const existing = requestCounts.get(clientIP);

  if (existing && currentTime < existing.resetTime) {
    if (existing.count >= 10) { // 10 requests per minute
      throw new TRPCError({
        code: 'TOO_MANY_REQUESTS',
        message: 'Rate limit exceeded. Please try again later.'
      });
    }
    existing.count++;
  } else {
    requestCounts.set(clientIP, {
      count: 1,
      resetTime: currentTime + 60000, // 1 minute
    });
  }

  return next();
});

// 9. Protected procedure creator
const protectedProcedure = t.procedure
  .use(errorHandler)
  .use(logger)
  .use(rateLimit)
  .use(isAuthed);

const adminProcedure = protectedProcedure.use(hasRole('admin'));

// 10. Create router with middleware
export const authRouter = t.router({
  // Public procedures
  login: t.procedure
    .input((val: unknown) => {
      if (typeof val !== 'object' || val === null) {
        throw new Error('Expected credentials object');
      }
      return val as { email: string; password: string };
    })
    .mutation(({ input }) => {
      // Simulate authentication
      const user = db.users.find(u => u.email === input.email);

      if (!user || input.password !== 'password') {
        throw new TRPCError({
          code: 'UNAUTHORIZED',
          message: 'Invalid credentials'
        });
      }

      const token = jwt.sign(
        { userId: user.id },
        process.env.JWT_SECRET || 'secret-key',
        { expiresIn: '24h' }
      );

      return { token, user };
    }),

  // Protected procedures
  getProfile: protectedProcedure
    .query(({ ctx }) => {
      return ctx.user;
    }),

  updateProfile: protectedProcedure
    .input((val: unknown) => {
      if (typeof val !== 'object' || val === null) {
        throw new Error('Expected profile data');
      }
      return val as { name?: string; email?: string };
    })
    .mutation(({ input, ctx }) => {
      if (!ctx.user) {
        throw new TRPCError({ code: 'UNAUTHORIZED' });
      }

      const userIndex = db.users.findIndex(u => u.id === ctx.user!.id);
      if (userIndex === -1) {
        throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found' });
      }

      // Update user
      const updatedUser = { ...db.users[userIndex] };
      if (input.name) updatedUser.name = input.name;
      if (input.email) updatedUser.email = input.email;

      db.users[userIndex] = updatedUser;

      return updatedUser;
    }),

  // Admin-only procedures
  getUsers: adminProcedure
    .query(() => {
      return db.users;
    }),

  deleteUser: adminProcedure
    .input((val: unknown) => {
      if (typeof val !== 'string') {
        throw new Error('Expected user ID string');
      }
      return val;
    })
    .mutation(({ input }) => {
      const userIndex = db.users.findIndex(u => u.id === input);
      if (userIndex === -1) {
        throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found' });
      }

      const deletedUser = db.users[userIndex];
      db.users.splice(userIndex, 1);

      return { success: true, deletedUser };
    }),

  // Post management with ownership validation
  createPost: protectedProcedure
    .input((val: unknown) => {
      if (typeof val !== 'object' || val === null) {
        throw new Error('Expected post data');
      }
      const { title, content } = val as { title?: unknown; content?: unknown };

      if (typeof title !== 'string' || typeof content !== 'string') {
        throw new Error('Title and content must be strings');
      }

      return { title, content };
    })
    .mutation(({ input, ctx }) => {
      const post = {
        id: Math.random().toString(36).substr(2, 9),
        title: input.title,
        content: input.content,
        authorId: ctx.user!.id,
        published: false,
        createdAt: new Date(),
      };

      db.posts.push(post);
      return post;
    }),

  // Procedure with multiple middleware
  getSensitiveData: protectedProcedure
    .use(logger)
    .use(rateLimit)
    .query(({ ctx }) => {
      // This procedure uses both auth middleware and additional middleware
      return {
        user: ctx.user,
        data: 'This is sensitive data only accessible to authenticated users',
        timestamp: new Date().toISOString(),
      };
    }),

  // Custom error handling
  riskyOperation: protectedProcedure
    .input((val: unknown) => {
      if (typeof val !== 'object' || val === null) {
        throw new Error('Expected config object');
      }
      return val as { shouldFail: boolean };
    })
    .mutation(({ input }) => {
      if (input.shouldFail) {
        throw new TRPCError({
          code: 'INTERNAL_SERVER_ERROR',
          message: 'The operation failed as requested',
          cause: new Error('Simulated failure')
        });
      }

      return { success: true, message: 'Operation completed successfully' };
    }),
});

// 11. Validation middleware example
const validateInput = <T>(validator: (input: unknown) => T) =>
  t.middleware(async ({ next, rawInput }) => {
    if (!rawInput) {
      throw new TRPCError({ code: 'BAD_REQUEST', message: 'Input is required' });
    }

    try {
      const parsed = validator(rawInput);
      return next({ ctx: { parsedInput: parsed } });
    } catch (error) {
      throw new TRPCError({
        code: 'BAD_REQUEST',
        message: `Validation error: ${error.message}`
      });
    }
  });

// Example with validation middleware
const validatedRouter = t.router({
  createUserWithValidation: t.procedure
    .input((val: unknown) => {
      if (typeof val !== 'object' || val === null) {
        throw new Error('Expected user object');
      }
      const user = val as { name: unknown; email: unknown; age: unknown };

      if (typeof user.name !== 'string' || user.name.length < 2) {
        throw new Error('Name must be at least 2 characters');
      }

      if (typeof user.email !== 'string' || !user.email.includes('@')) {
        throw new Error('Valid email is required');
      }

      if (typeof user.age !== 'number' || user.age < 18) {
        throw new Error('Age must be at least 18');
      }

      return user as { name: string; email: string; age: number };
    })
    .use(validateInput((input: unknown) => {
      // Additional validation logic here
      return input as { name: string; email: string; age: number };
    }))
    .mutation(({ ctx }) => {
      const { parsedInput } = ctx as { parsedInput: { name: string; email: string; age: number } };

      const newUser: User = {
        id: Math.random().toString(36).substr(2, 9),
        email: parsedInput.email,
        name: parsedInput.name,
        role: 'user'
      };

      db.users.push(newUser);
      return { success: true, user: newUser };
    }),
});

// 12. Combined router
export const combinedRouter = t.router({
  auth: authRouter,
  validated: validatedRouter,
});

export type AppRouter = typeof combinedRouter;

// 13. Express server setup with middleware
const app = express();
app.use(express.json());

app.use('/trpc', createExpressMiddleware({
  router: combinedRouter,
  createContext: ({ req }) => ({ req }),
  onError: ({ error, type, path, input, ctx, req }) => {
    // Global error handling
    console.error(`tRPC Error on ${type.toUpperCase()} ${path}:`, error);

    if (error.code === 'INTERNAL_SERVER_ERROR') {
      // Log internal errors
      console.error('Internal error details:', error.cause);
    }
  },
}));

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`tRPC server with middleware running on port ${PORT}`);
});

export default app;

💻 tRPC React Integration typescript

🟡 intermediate ⭐⭐⭐⭐

tRPC Integration mit React einschließlich Hooks, Queries, Mutations, Subscriptions und TanStack Query Integration

⏱️ 50 min 🏷️ trpc, react, frontend, integration, typescript
Prerequisites: tRPC basics, React hooks, TanStack Query, TypeScript
// tRPC React Integration Example
// Frontend React integration with tRPC and TanStack Query

import React, { useState } from 'react';
import { createTRPCReact } from '@trpc/react-query';
import { httpBatchLink } from '@trpc/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

// 1. tRPC React Query setup
export const trpc = createTRPCReact<AppRouter>();

// 2. Create tRPC client
const getTRPCClient = () => {
  return trpc.createClient({
    links: [
      httpBatchLink({
        url: 'http://localhost:3000/trpc',
        headers: () => {
          const token = localStorage.getItem('auth-token');
          return token ? { Authorization: `Bearer ${token}` } : {};
        },
      }),
    ],
  });
};

// 3. Query Client setup
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5, // 5 minutes
      retry: (failureCount, error) => {
        // Don't retry on 4xx errors
        if (error.data?.code === 'UNAUTHORIZED' || error.data?.code === 'FORBIDDEN') {
          return false;
        }
        return failureCount < 3;
      },
    },
  },
});

// 4. App wrapper component
export function App() {
  const [trpcClient] = useState(() => getTRPCClient());

  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        <div className="app">
          <Navigation />
          <Routes />
        </div>
      </QueryClientProvider>
    </trpc.Provider>
  );
}

// 5. Navigation component
function Navigation() {
  const utils = trpc.useContext();

  const handleLogout = () => {
    localStorage.removeItem('auth-token');
    // Invalidate all queries when logging out
    utils.auth.getProfile.invalidate();
  };

  return (
    <nav>
      <ul>
        <li><a href="/">Home</a></li>
        <li><a href="/profile">Profile</a></li>
        <li><a href="/posts">Posts</a></li>
      </ul>
      <button onClick={handleLogout}>Logout</button>
    </nav>
  );
}

// 6. Authentication component
function LoginForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  const login = trpc.auth.login.useMutation({
    onSuccess: (data) => {
      localStorage.setItem('auth-token', data.token);
      // Invalidate and refetch user profile
      trpc.auth.getProfile.invalidate();
    },
    onError: (error) => {
      alert(`Login failed: ${error.message}`);
    },
  });

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    login.mutate({ email, password });
  };

  return (
    <form onSubmit={handleSubmit}>
      <h2>Login</h2>
      <div>
        <label>Email:</label>
        <input
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          required
        />
      </div>
      <div>
        <label>Password:</label>
        <input
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          required
        />
      </div>
      <button type="submit" disabled={login.isLoading}>
        {login.isLoading ? 'Logging in...' : 'Login'}
      </button>
      {login.error && <p className="error">{login.error.message}</p>}
    </form>
  );
}

// 7. Profile component with tRPC hooks
function Profile() {
  const { data: user, isLoading, error } = trpc.auth.getProfile.useQuery();
  const utils = trpc.useContext();

  const [isEditing, setIsEditing] = useState(false);
  const [editName, setEditName] = useState(user?.name || '');

  const updateProfile = trpc.auth.updateProfile.useMutation({
    onSuccess: () => {
      setIsEditing(false);
      // Invalidate to refetch updated data
      utils.auth.getProfile.invalidate();
    },
  });

  const handleSave = () => {
    updateProfile.mutate({ name: editName });
  };

  if (isLoading) return <div>Loading profile...</div>;
  if (error) return <div>Error: {error.message}</div>;
  if (!user) return <div>Please log in to view your profile</div>;

  return (
    <div className="profile">
      <h2>Profile</h2>
      <div>
        <strong>Email:</strong> {user.email}
      </div>
      <div>
        <strong>Role:</strong> {user.role}
      </div>
      <div>
        <strong>Name:</strong>
        {isEditing ? (
          <>
            <input
              type="text"
              value={editName}
              onChange={(e) => setEditName(e.target.value)}
            />
            <button onClick={handleSave} disabled={updateProfile.isLoading}>
              {updateProfile.isLoading ? 'Saving...' : 'Save'}
            </button>
            <button onClick={() => setIsEditing(false)}>Cancel</button>
          </>
        ) : (
          <>
            {user.name}
            <button onClick={() => {
              setEditName(user.name);
              setIsEditing(true);
            }}>
              Edit
            </button>
          </>
        )}
      </div>
      {updateProfile.error && (
        <p className="error">{updateProfile.error.message}</p>
      )}
    </div>
  );
}

// 8. Posts management component
function PostsManagement() {
  const { data: posts, isLoading } = trpc.auth.createPost.useQuery(undefined, {
    // Custom query key to fetch posts
    queryKey: ['posts'],
    // This is a workaround since we don't have a dedicated getPosts procedure
  });

  const createPost = trpc.auth.createPost.useMutation();
  const utils = trpc.useContext();

  const [newPost, setNewPost] = useState({ title: '', content: '' });

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    createPost.mutate(newPost, {
      onSuccess: () => {
        setNewPost({ title: '', content: '' });
        // Invalidate posts list
        utils.auth.createPost.invalidate();
      },
    });
  };

  if (isLoading) return <div>Loading posts...</div>;

  return (
    <div className="posts">
      <h2>Create Post</h2>
      <form onSubmit={handleSubmit}>
        <div>
          <input
            type="text"
            placeholder="Title"
            value={newPost.title}
            onChange={(e) => setNewPost({ ...newPost, title: e.target.value })}
            required
          />
        </div>
        <div>
          <textarea
            placeholder="Content"
            value={newPost.content}
            onChange={(e) => setNewPost({ ...newPost, content: e.target.value })}
            required
          />
        </div>
        <button type="submit" disabled={createPost.isLoading}>
          {createPost.isLoading ? 'Creating...' : 'Create Post'}
        </button>
        {createPost.error && (
          <p className="error">{createPost.error.message}</p>
        )}
      </form>

      <h3>Posts</h3>
      {posts && (
        <div className="posts-list">
          {/* Render posts here */}
          <p>Posts would be rendered here with actual data</p>
        </div>
      )}
    </div>
  );
}

// 9. Data fetching with custom hooks
function useUserData() {
  const { data: user, isLoading: userLoading } = trpc.auth.getProfile.useQuery();
  const { data: posts, isLoading: postsLoading } = trpc.auth.createPost.useQuery(undefined, {
    queryKey: ['user-posts'],
  });

  return {
    user,
    posts,
    isLoading: userLoading || postsLoading,
  };
}

function UserDashboard() {
  const { user, posts, isLoading } = useUserData();

  if (isLoading) return <div>Loading dashboard...</div>;
  if (!user) return <div>Please log in</div>;

  return (
    <div className="dashboard">
      <h2>Welcome, {user.name}!</h2>
      <p>You have {posts?.length || 0} posts</p>

      <div className="stats">
        <div className="stat-card">
          <h3>{user.role === 'admin' ? 'Admin Dashboard' : 'User Dashboard'}</h3>
          <p>Role: {user.role}</p>
        </div>
      </div>
    </div>
  );
}

// 10. Optimistic updates example
function TodoList() {
  const { data: todos = [] } = trpc.todos.list.useQuery();
  const utils = trpc.useContext();

  const addTodo = trpc.todos.add.useMutation({
    onMutate: async (newTodo) => {
      // Cancel any outgoing refetches
      await utils.todos.list.cancel();

      // Snapshot the previous value
      const previousTodos = utils.todos.list.getData();

      // Optimistically update to the new value
      utils.todos.list.setData(undefined, (old) => [
        ...(old || []),
        { ...newTodo, id: 'temp-' + Date.now(), completed: false }
      ]);

      // Return context with the previous value
      return { previousTodos };
    },
    onError: (err, newTodo, context) => {
      // If the mutation fails, use the context returned from onMutate
      utils.todos.list.setData(undefined, context?.previousTodos);
    },
    onSettled: () => {
      // Always refetch after error or success
      utils.todos.list.invalidate();
    },
  });

  return (
    <div>
      <h2>Todos</h2>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>{todo.title}</li>
        ))}
      </ul>
      <button
        onClick={() => addTodo.mutate({ title: 'New Todo' })}
        disabled={addTodo.isLoading}
      >
        Add Todo
      </button>
    </div>
  );
}

// 11. Subscription component
function RealTimeData() {
  const { data, error, isLoading } = trpc.onHello.useSubscription(undefined, {
    onData(data) {
      console.log('Received data:', data);
    },
    onError(err) {
      console.error('Subscription error:', err);
    },
  });

  if (isLoading) return <div>Connecting to real-time updates...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <div>
      <h2>Real-time Updates</h2>
      <p>Latest message: {data?.greeting}</p>
    </div>
  );
}

// 12. Error boundary for tRPC errors
class TrpcErrorBoundary extends React.Component<
  { children: React.ReactNode },
  { hasError: boolean; error?: Error }
> {
  constructor(props: { children: React.ReactNode }) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error: Error) {
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    console.error('tRPC Error caught by boundary:', error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return (
        <div className="error-boundary">
          <h2>Something went wrong</h2>
          <p>{this.state.error?.message}</p>
          <button onClick={() => this.setState({ hasError: false, error: undefined })}>
            Try again
          </button>
        </div>
      );
    }

    return this.props.children;
  }
}

// 13. Main app routes
function Routes() {
  const [route, setRoute] = useState('/');

  const renderRoute = () => {
    switch (route) {
      case '/':
        return <UserDashboard />;
      case '/profile':
        return <Profile />;
      case '/posts':
        return <PostsManagement />;
      case '/todos':
        return <TodoList />;
      case '/realtime':
        return <RealTimeData />;
      default:
        return <LoginForm />;
    }
  };

  return (
    <TrpcErrorBoundary>
      <div>
        <nav style={{ marginBottom: '20px' }}>
          <button onClick={() => setRoute('/')}>Dashboard</button>
          <button onClick={() => setRoute('/profile')}>Profile</button>
          <button onClick={() => setRoute('/posts')}>Posts</button>
          <button onClick={() => setRoute('/todos')}>Todos</button>
          <button onClick={() => setRoute('/realtime')}>Real-time</button>
        </nav>
        {renderRoute()}
      </div>
    </TrpcErrorBoundary>
  );
}

// 14. Custom hook for offline support
function useOfflineSupport() {
  const [isOnline, setIsOnline] = useState(navigator.onLine);

  React.useEffect(() => {
    const handleOnline = () => setIsOnline(true);
    const handleOffline = () => setIsOnline(false);

    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);

    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);

  return isOnline;
}

// Usage example
function AppWithOfflineSupport() {
  const isOnline = useOfflineSupport();

  return (
    <div className={isOnline ? 'online' : 'offline'}>
      {!isOnline && <div className="offline-warning">You are offline</div>}
      <App />
    </div>
  );
}

export default AppWithOfflineSupport;