Svelte + GraphQL

GraphQLは、APIのためのクエリ言語で、型安全で効率的なデータフェッチングを実現します。Svelteと組み合わせることで、必要なデータだけを取得する高速なアプリケーションを構築できます。

GraphQLの利点

ダイアグラムを読み込み中...

主な特徴

  • 📊 単一エンドポイント - すべてのデータを1つのエンドポイントから取得
  • 🎯 必要なデータのみ - オーバーフェッチング・アンダーフェッチングを解消
  • 🔍 型安全 - スキーマによる強力な型システム
  • リアルタイム - Subscriptionによるリアルタイム更新
  • 🔄 キャッシュ - 正規化されたキャッシュで効率的

Apollo Client統合

1. セットアップ

npm create vite@latest my-svelte-apollo -- --template svelte-ts
cd my-svelte-apollo
npm install @apollo/client graphql
bash

2. Apollo Clientの設定

// src/lib/apollo.ts
import {
  ApolloClient,
  InMemoryCache,
  createHttpLink,
  split
} from '@apollo/client/core';
import { WebSocketLink } from '@apollo/client/link/ws';
import { getMainDefinition } from '@apollo/client/utilities';

// HTTPリンク
const httpLink = createHttpLink({
  uri: import.meta.env.VITE_GRAPHQL_ENDPOINT || 'http://localhost:4000/graphql',
  headers: {
    authorization: localStorage.getItem('token') || ''
  }
});

// WebSocketリンク(Subscription用)
const wsLink = new WebSocketLink({
  uri: import.meta.env.VITE_GRAPHQL_WS_ENDPOINT || 'ws://localhost:4000/graphql',
  options: {
    reconnect: true,
    connectionParams: {
      authorization: localStorage.getItem('token') || ''
    }
  }
});

// リンクの分割(Query/MutationとSubscription)
const splitLink = split(
  ({ query }) => {
    const definition = getMainDefinition(query);
    return (
      definition.kind === 'OperationDefinition' &&
      definition.operation === 'subscription'
    );
  },
  wsLink,
  httpLink
);

// Apolloクライアントの作成
export const apolloClient = new ApolloClient({
  link: splitLink,
  cache: new InMemoryCache(),
  defaultOptions: {
    watchQuery: {
      fetchPolicy: 'cache-and-network'
    }
  }
});
typescript

3. GraphQLスキーマと型生成

// schema.graphql
const schema = `
type User {
  id: ID!
  name: String!
  email: String!
  posts: [Post!]!
}

type Post {
  id: ID!
  title: String!
  content: String!
  author: User!
  createdAt: String!
}

type Query {
  users: [User!]!
  user(id: ID!): User
  posts(limit: Int, offset: Int): [Post!]!
}

type Mutation {
  createPost(input: CreatePostInput!): Post!
  updatePost(id: ID!, input: UpdatePostInput!): Post!
  deletePost(id: ID!): Boolean!
}

type Subscription {
  postCreated: Post!
  postUpdated(id: ID!): Post!
}

input CreatePostInput {
  title: String!
  content: String!
  authorId: ID!
}

input UpdatePostInput {
  title: String
  content: String
}
`;
typescript
# 型を自動生成
npm install -D @graphql-codegen/cli @graphql-codegen/typescript
npx graphql-codegen init
npx graphql-codegen
bash

4. Svelteストアとの統合

// src/lib/stores/graphql.svelte.ts
import { apolloClient } from '$lib/apollo';
import { gql } from '@apollo/client/core';
import type { Post, User } from '$lib/generated/graphql';

class GraphQLStore {
  posts = $state<Post[]>([]);
  loading = $state(false);
  error = $state<Error | null>(null);
  
  async fetchPosts(limit = 10) {
    this.loading = true;
    this.error = null;
    
    try {
      const result = await apolloClient.query({
        query: gql`
          query GetPosts($limit: Int) {
            posts(limit: $limit) {
              id
              title
              content
              createdAt
              author {
                id
                name
              }
            }
          }
        `,
        variables: { limit }
      });
      
      this.posts = result.data.posts;
    } catch (err) {
      this.error = err as Error;
    } finally {
      this.loading = false;
    }
  }
  
  async createPost(title: string, content: string, authorId: string) {
    const result = await apolloClient.mutate({
      mutation: gql`
        mutation CreatePost($input: CreatePostInput!) {
          createPost(input: $input) {
            id
            title
            content
            author {
              id
              name
            }
            createdAt
          }
        }
      `,
      variables: {
        input: { title, content, authorId }
      }
    });
    
    if (result.data) {
      this.posts = [result.data.createPost, ...this.posts];
    }
  }
  
  subscribeToNewPosts() {
    const subscription = apolloClient.subscribe({
      query: gql`
        subscription OnPostCreated {
          postCreated {
            id
            title
            content
            author {
              id
              name
            }
            createdAt
          }
        }
      `
    }).subscribe({
      next: ({ data }) => {
        if (data?.postCreated) {
          this.posts = [data.postCreated, ...this.posts];
        }
      }
    });
    
    return () => subscription.unsubscribe();
  }
}

export const graphqlStore = new GraphQLStore();
typescript

urql統合(軽量な代替案)

1. urqlセットアップ

npm install urql graphql
bash

2. urqlクライアント設定

// src/lib/urql.ts
import { createClient, defaultExchanges, subscriptionExchange } from 'urql';
import { createClient as createWSClient } from 'graphql-ws';

const wsClient = createWSClient({
  url: 'ws://localhost:4000/graphql',
  connectionParams: {
    authorization: localStorage.getItem('token') || ''
  }
});

export const urqlClient = createClient({
  url: 'http://localhost:4000/graphql',
  fetchOptions: {
    headers: {
      authorization: localStorage.getItem('token') || ''
    }
  },
  exchanges: [
    ...defaultExchanges,
    subscriptionExchange({
      forwardSubscription: (operation) => ({
        subscribe: (sink) => ({
          unsubscribe: wsClient.subscribe(operation, sink)
        })
      })
    })
  ]
});
typescript

3. Svelteコンポーネントでの使用

<!-- src/lib/components/PostList.svelte -->
<script lang="ts">
  import { urqlClient } from '$lib/urql';
  import { gql } from 'urql';
  
  interface Post {
    id: string;
    title: string;
    content: string;
    author: {
      name: string;
    };
  }
  
  let posts = $state<Post[]>([]);
  let loading = $state(true);
  let error = $state<Error | null>(null);
  
  const POSTS_QUERY = gql`
    query GetPosts($limit: Int!) {
      posts(limit: $limit) {
        id
        title
        content
        author {
          name
        }
      }
    }
  `;
  
  async function fetchPosts() {
    loading = true;
    error = null;
    
    const result = await urqlClient.query(POSTS_QUERY, { limit: 10 });
    
    if (result.error) {
      error = result.error;
    } else if (result.data) {
      posts = result.data.posts;
    }
    
    loading = false;
  }
  
  // 初期ロード
  $effect(() => {
    fetchPosts();
  });
</script>

{#if loading}
  <p>読み込み中...</p>
{:else if error}
  <p class="error">エラー: {error.message}</p>
{:else}
  <div class="posts">
    {#each posts as post (post.id)}
      <article>
        <h2>{post.title}</h2>
        <p>{post.content}</p>
        <small>by {post.author.name}</small>
      </article>
    {/each}
  </div>
{/if}
svelte

Hasura統合(インスタントGraphQL)

1. Hasuraセットアップ

# docker-compose.yml
version: '3.8'
services:
  postgres:
    image: postgres:15
    environment:
      POSTGRES_PASSWORD: postgrespassword
    volumes:
      - db_data:/var/lib/postgresql/data

  hasura:
    image: hasura/graphql-engine:latest
    ports:
      - "8080:8080"
    depends_on:
      - postgres
    environment:
      HASURA_GRAPHQL_DATABASE_URL: postgres://postgres:postgrespassword@postgres:5432/postgres
      HASURA_GRAPHQL_ENABLE_CONSOLE: "true"
      HASURA_GRAPHQL_ADMIN_SECRET: myadminsecret

volumes:
  db_data:
yaml

2. Hasuraクライアント

// src/lib/hasura.ts
import { createClient } from 'urql';

export const hasuraClient = createClient({
  url: 'http://localhost:8080/v1/graphql',
  fetchOptions: () => {
    const token = localStorage.getItem('token');
    return {
      headers: {
        'x-hasura-admin-secret': 'myadminsecret',
        ...(token ? { authorization: `Bearer ${token}` } : {})
      }
    };
  }
});

// リアルタイムサブスクリプション
export function subscribeToTable<T>(
  tableName: string,
  fields: string[]
) {
  const subscription = gql`
    subscription Subscribe {
      ${tableName}(order_by: {created_at: desc}) {
        ${fields.join('\n')}
      }
    }
  `;
  
  return hasuraClient.subscription(subscription);
}
typescript

ベストプラクティス

1. Fragment の活用

// src/lib/graphql/fragments.ts
import { gql } from '@apollo/client/core';

export const USER_FRAGMENT = gql`
  fragment UserInfo on User {
    id
    name
    email
    avatar
  }
`;

export const POST_FRAGMENT = gql`
  fragment PostInfo on Post {
    id
    title
    content
    createdAt
    author {
      ...UserInfo
    }
  }
  ${USER_FRAGMENT}
`;

// 使用例
const GET_POSTS = gql`
  query GetPosts {
    posts {
      ...PostInfo
    }
  }
  ${POST_FRAGMENT}
`;
typescript

2. エラーハンドリング

// src/lib/utils/graphql-error.ts
import type { ApolloError } from '@apollo/client/core';

export function handleGraphQLError(error: ApolloError): string {
  // ネットワークエラー
  if (error.networkError) {
    return 'ネットワークエラーが発生しました';
  }
  
  // GraphQLエラー
  if (error.graphQLErrors?.length > 0) {
    return error.graphQLErrors[0].message;
  }
  
  return '予期しないエラーが発生しました';
}
typescript

3. オプティミスティックUI

// src/lib/stores/optimistic.svelte.ts
class OptimisticStore {
  items = $state<Item[]>([]);
  
  async createItem(input: CreateItemInput) {
    // オプティミスティック更新
    const optimisticItem = {
      id: `temp-${Date.now()}`,
      ...input,
      __typename: 'Item'
    };
    
    this.items = [optimisticItem, ...this.items];
    
    try {
      const result = await apolloClient.mutate({
        mutation: CREATE_ITEM,
        variables: { input },
        optimisticResponse: {
          createItem: optimisticItem
        },
        update: (cache, { data }) => {
          // キャッシュ更新
          const newItem = data?.createItem;
          if (newItem) {
            cache.modify({
              fields: {
                items(existingItems = []) {
                  const newItemRef = cache.writeFragment({
                    data: newItem,
                    fragment: ITEM_FRAGMENT
                  });
                  return [newItemRef, ...existingItems];
                }
              }
            });
          }
        }
      });
      
      // 一時アイテムを実際のアイテムに置き換え
      if (result.data?.createItem) {
        this.items = this.items.map(item =>
          item.id === optimisticItem.id ? result.data.createItem : item
        );
      }
    } catch (error) {
      // エラー時はオプティミスティック更新を取り消し
      this.items = this.items.filter(item => item.id !== optimisticItem.id);
      throw error;
    }
  }
}
typescript

4. ページネーション

// src/lib/stores/pagination.svelte.ts
class PaginationStore {
  items = $state<Item[]>([]);
  hasMore = $state(true);
  loading = $state(false);
  cursor = $state<string | null>(null);
  
  async loadMore() {
    if (this.loading || !this.hasMore) return;
    
    this.loading = true;
    const result = await apolloClient.query({
      query: GET_ITEMS,
      variables: {
        limit: 20,
        cursor: this.cursor
      }
    });
    
    const newItems = result.data.items;
    this.items = [...this.items, ...newItems];
    this.hasMore = newItems.length === 20;
    this.cursor = newItems[newItems.length - 1]?.id || null;
    this.loading = false;
  }
  
  // 無限スクロール
  setupInfiniteScroll(element: HTMLElement) {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting) {
          this.loadMore();
        }
      },
      { threshold: 0.1 }
    );
    
    observer.observe(element);
    return () => observer.disconnect();
  }
}
typescript
GraphQLツール
  • GraphQL Playground - APIエクスプローラー
  • GraphQL Code Generator - 型の自動生成
  • Apollo DevTools - デバッグツール
  • Hasura Console - 管理画面

まとめ

Svelte + GraphQLの組み合わせは以下のケースに最適です。

  • 型安全 - スキーマから型を自動生成
  • 効率的なフェッチ - 必要なデータのみ取得
  • リアルタイム - Subscriptionでリアルタイム更新
  • 複雑なデータ - ネストした関連データも一度に取得

他のアーキテクチャパターンも参考にしてください。

Last update at: 2025/12/08 17:18:53