Orval + SWR を React Native で使う際の注意点と React Query との比較

React Router で Orval + SWR を使用している環境から、React Native への移行時に必要な対応と、Orval + React Query との実装差分をまとめる。

→TanStack Queryは?というあたりについてはそこまでまにあわなかったごめんなのでべつでかく。


目次

  1. 概要:Orval が生成するコードの違い
  2. SWR を React Native で使う際の必須設定
  3. キャッシュの永続化(オフライン対応)
  4. SWR vs React Query:機能比較
  5. Orval 設定の違い
  6. 実装パターン別コード例
  7. 移行時のチェックリスト

1. 概要

Orval とは

Orval は OpenAPI (Swagger) 仕様から型安全な API クライアントを自動生成するツール。

サポートするクライアント - swr - SWR フック生成 - react-query - TanStack Query フック生成 - axios / axios-functions - Axios ベースのクライアント - fetch - Fetch API ベース - その他(vue-query, svelte-query, angular 等)

Web と React Native での根本的な違い

項目 Web (React Router) React Native
フォーカス検知 window.focus イベント AppState API
ネットワーク状態 navigator.onLine @react-native-community/netinfo
キャッシュ永続化 localStorage AsyncStorage / MMKV
バックグラウンド タブ切り替え アプリがバックグラウンドへ

2. SWR を React Native で使う際の必須設定

2.1 問題点

SWR の revalidateOnFocusrevalidateOnReconnectWeb 専用 。React Native では以下が動作しない

// ❌ これらのオプションは React Native でデフォルトでは動作しない
useSWR('/api/user', fetcher, {
  revalidateOnFocus: true,    // window.focus を使用
  revalidateOnReconnect: true // navigator.onLine を使用
})

2.2 解決策 A:公式ドキュメントの方法(手動設定)

// providers/SWRProvider.tsx
import { AppState, AppStateStatus } from 'react-native';
import NetInfo from '@react-native-community/netinfo';
import { SWRConfig } from 'swr';

export function SWRProvider({ children }: { children: React.ReactNode }) {
  return (
    <SWRConfig
      value={{
        provider: () => new Map(),

        // ネットワーク状態の検知
        isOnline() {
          return true; // NetInfo で実際の状態を返すことも可能
        },

        // アプリがアクティブかどうか
        isVisible() {
          return true;
        },

        // フォーカスイベントの初期化
        initFocus(callback) {
          let appState = AppState.currentState;

          const onAppStateChange = (nextAppState: AppStateStatus) => {
            // バックグラウンドからアクティブに復帰した時
            if (
              appState.match(/inactive|background/) &&
              nextAppState === 'active'
            ) {
              callback();
            }
            appState = nextAppState;
          };

          const subscription = AppState.addEventListener(
            'change',
            onAppStateChange
          );

          return () => {
            subscription.remove();
          };
        },

        // ネットワーク復帰イベントの初期化
        initReconnect(callback) {
          const unsubscribe = NetInfo.addEventListener((state) => {
            if (state.isConnected) {
              callback();
            }
          });

          return () => {
            unsubscribe();
          };
        },
      }}
    >
      {children}
    </SWRConfig>
  );
}

2.3 解決策 B:@nandorojo/swr-react-native(推奨)

より簡単な方法として、専用ライブラリを使用

# インストール
yarn add @nandorojo/swr-react-native
yarn add @react-native-community/netinfo
// 方法1: useSWR を置き換え
import useSWRNative from '@nandorojo/swr-react-native';

function UserProfile() {
  // useSWR と同じ API、React Native 対応済み
  const { data, error, mutate } = useSWRNative('/api/user', fetcher);

  return <Text>{data?.name}</Text>;
}

// 方法2: 既存の useSWR に追加(useSWRInfinite 使用時に便利)
import useSWR from 'swr';
import { useSWRNativeRevalidate } from '@nandorojo/swr-react-native';

function UserProfile() {
  const { data, mutate } = useSWR('/api/user', fetcher);

  // 再検証ロジックを追加
  useSWRNativeRevalidate({ mutate });

  return <Text>{data?.name}</Text>;
}

3. キャッシュの永続化

3.1 SWR のキャッシュ問題

重要: SWR はデフォルトで インメモリキャッシュ を使用。アプリを終了するとキャッシュは消える。

// ❌ アプリ再起動でキャッシュが消える
<SWRConfig value={{ provider: () => new Map() }}>
  {children}
</SWRConfig>

3.2 SWR + AsyncStorage での永続化

// lib/swr-cache-provider.ts
import AsyncStorage from '@react-native-async-storage/async-storage';

const CACHE_KEY = 'swr-cache';

export function createAsyncStorageCacheProvider() {
  const map = new Map<string, any>();

  // 起動時にキャッシュを復元
  async function initCache() {
    try {
      const stored = await AsyncStorage.getItem(CACHE_KEY);
      if (stored) {
        const parsed = JSON.parse(stored);
        Object.entries(parsed).forEach(([key, value]) => {
          map.set(key, value);
        });
      }
    } catch (e) {
      console.error('Failed to restore cache:', e);
    }
  }

  // キャッシュを保存
  async function persistCache() {
    try {
      const obj: Record<string, any> = {};
      map.forEach((value, key) => {
        obj[key] = value;
      });
      await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(obj));
    } catch (e) {
      console.error('Failed to persist cache:', e);
    }
  }

  return {
    initCache,
    provider: () => {
      return {
        get: (key: string) => map.get(key),
        set: (key: string, value: any) => {
          map.set(key, value);
          // 変更時に保存(スロットリング推奨)
          persistCache();
        },
        delete: (key: string) => {
          map.delete(key);
          persistCache();
        },
        keys: () => map.keys(),
      };
    },
  };
}

3.3 swr-sync-native-storage パッケージ

yarn add swr-sync-storage @react-native-async-storage/async-storage
import { syncWithStorage } from 'swr-sync-storage';
import AsyncStorage from '@react-native-async-storage/async-storage';

// アプリ起動時に一度だけ呼び出し
const { success, unsubscribe } = await syncWithStorage({
  storage: AsyncStorage,
  // オプション
  excludeKeys: [/^private/], // 特定のキーを除外
});

3.4 React Query のキャッシュ永続化(比較)

React Query は公式プラグインで永続化をサポート:

yarn add @tanstack/query-async-storage-persister
yarn add @tanstack/react-query-persist-client
// providers/QueryProvider.tsx
import { QueryClient } from '@tanstack/react-query';
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
import AsyncStorage from '@react-native-async-storage/async-storage';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      gcTime: 1000 * 60 * 60 * 24, // 24時間
      staleTime: 1000 * 60 * 5,    // 5分
    },
  },
});

const asyncStoragePersister = createAsyncStoragePersister({
  storage: AsyncStorage,
  key: 'REACT_QUERY_CACHE',
});

export function QueryProvider({ children }: { children: React.ReactNode }) {
  return (
    <PersistQueryClientProvider
      client={queryClient}
      persistOptions={{
        persister: asyncStoragePersister,
        maxAge: 1000 * 60 * 60 * 24, // 24時間
      }}
    >
      {children}
    </PersistQueryClientProvider>
  );
}

3.5 MMKV を使用した高速キャッシュ(推奨)

MMKV は AsyncStorage より 約30倍高速 とのこと。 こちらはちゃんと試してない。あとで試す。

yarn add react-native-mmkv
// lib/mmkv-storage.ts
import { MMKV } from 'react-native-mmkv';

export const storage = new MMKV({
  id: 'query-cache',
});

// React Query 用アダプター
export const mmkvStorageAdapter = {
  getItem: (key: string) => {
    const value = storage.getString(key);
    return value ?? null;
  },
  setItem: (key: string, value: string) => {
    storage.set(key, value);
  },
  removeItem: (key: string) => {
    storage.delete(key);
  },
};

// SWR 用 Provider
export function createMMKVCacheProvider() {
  return {
    get: (key: string) => {
      const value = storage.getString(key);
      return value ? JSON.parse(value) : undefined;
    },
    set: (key: string, value: any) => {
      storage.set(key, JSON.stringify(value));
    },
    delete: (key: string) => {
      storage.delete(key);
    },
    keys: function* () {
      const allKeys = storage.getAllKeys();
      for (const key of allKeys) {
        yield key;
      }
    },
  };
}

4. SWR vs React Query:機能比較

4.1 機能対照表

機能 SWR React Query 備考
バンドルサイズ ~5.3KB ~16.2KB SWR は約1/3
DevTools ❌ なし ✅ 公式提供 デバッグに大きな差
キャッシュ永続化 自前実装必要 公式プラグイン React Query が楽
Optimistic Updates △ 基本的 ✅ 充実 rollback 等
ページネーション useSWRInfinite useInfiniteQuery 同等
Mutation useSWRMutation useMutation React Query が豊富
クエリ無効化 mutate(key) invalidateQueries React Query が柔軟
リトライ 同等
並列フェッチ 同等
React Native 対応 追加設定必要 ほぼそのまま React Query が楽

4.2 Orval 生成コードの違い

SWR の場合

// 生成されるフック (Orval + SWR)
export const useGetUser = (
  userId: string,
  options?: SWRConfiguration<User, Error>
) => {
  return useSWR<User, Error>(
    userId ? `/api/users/${userId}` : null,
    () => customFetch<User>({ url: `/api/users/${userId}` }),
    options
  );
};

// Mutation は useSWRMutation
export const useCreateUser = (
  options?: SWRMutationConfiguration<User, Error, string, CreateUserBody>
) => {
  return useSWRMutation<User, Error, string, CreateUserBody>(
    '/api/users',
    (_url, { arg }) => customFetch<User>({
      url: '/api/users',
      method: 'POST',
      data: arg,
    }),
    options
  );
};

React Query の場合

// 生成されるフック (Orval + React Query)
export const useGetUser = (
  userId: string,
  options?: UseQueryOptions<User, Error>
) => {
  return useQuery<User, Error>({
    queryKey: ['users', userId],
    queryFn: () => customFetch<User>({ url: `/api/users/${userId}` }),
    enabled: !!userId,
    ...options,
  });
};

// Mutation
export const useCreateUser = (
  options?: UseMutationOptions<User, Error, CreateUserBody>
) => {
  return useMutation<User, Error, CreateUserBody>({
    mutationFn: (data) => customFetch<User>({
      url: '/api/users',
      method: 'POST',
      data,
    }),
    ...options,
  });
};

5. Orval 設定の違い

5.1 SWR 用設定

// orval.config.ts
import { defineConfig } from 'orval';

export default defineConfig({
  petstore: {
    input: './openapi.yaml',
    output: {
      target: './src/api/generated.ts',
      client: 'swr',

      // HTTP クライアント(fetch or axios)
      httpClient: 'fetch',

      // SWR 固有オプション
      override: {
        swr: {
          // useSWRInfinite を生成
          useInfinite: true,

          // useSWRMutation を生成(GET 以外のリクエスト用)
          useMutation: true,

          // デフォルトオプション
          options: {
            revalidateOnFocus: false,
            revalidateOnReconnect: false,
          },
        },

        // カスタムフェッチャー
        mutator: {
          path: './src/lib/custom-fetch.ts',
          name: 'customFetch',
        },
      },
    },
  },
});

5.2 React Query 用設定

// orval.config.ts
import { defineConfig } from 'orval';

export default defineConfig({
  petstore: {
    input: './openapi.yaml',
    output: {
      target: './src/api/generated.ts',
      client: 'react-query',

      httpClient: 'fetch',

      override: {
        query: {
          // useInfiniteQuery を生成
          useInfinite: true,

          // useSuspenseQuery を生成
          useSuspense: false,

          // React Query v5 対応
          version: 5,

          // デフォルトオプション
          options: {
            staleTime: 1000 * 60 * 5,
            gcTime: 1000 * 60 * 60,
          },
        },

        mutator: {
          path: './src/lib/custom-fetch.ts',
          name: 'customFetch',
        },
      },
    },
  },
});

5.3 カスタムフェッチャー(共通)

// src/lib/custom-fetch.ts
import { Platform } from 'react-native';

const BASE_URL = __DEV__
  ? Platform.select({
      ios: 'http://localhost:3000',
      android: 'http://10.0.2.2:3000', // Android エミュレータ
      default: 'http://localhost:3000',
    })
  : 'https://api.example.com';

interface RequestConfig {
  url: string;
  method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
  data?: unknown;
  params?: Record<string, string>;
  headers?: Record<string, string>;
  signal?: AbortSignal;
}

export async function customFetch<T>({
  url,
  method = 'GET',
  data,
  params,
  headers,
  signal,
}: RequestConfig): Promise<T> {
  const queryString = params
    ? '?' + new URLSearchParams(params).toString()
    : '';

  const response = await fetch(`${BASE_URL}${url}${queryString}`, {
    method,
    headers: {
      'Content-Type': 'application/json',
      ...headers,
    },
    body: data ? JSON.stringify(data) : undefined,
    signal,
  });

  if (!response.ok) {
    const error = await response.json().catch(() => ({}));
    throw new Error(error.message || `HTTP ${response.status}`);
  }

  return response.json();
}

6. 実装パターン別コード例

6.1 基本的なデータ取得

SWR

// components/UserProfile.tsx
import { useGetUser } from '@/api/generated';
import { View, Text, ActivityIndicator } from 'react-native';

export function UserProfile({ userId }: { userId: string }) {
  const { data, error, isLoading, mutate } = useGetUser(userId);

  if (isLoading) return <ActivityIndicator />;
  if (error) return <Text>Error: {error.message}</Text>;

  return (
    <View>
      <Text>{data?.name}</Text>
      <Button onPress={() => mutate()} title="Refresh" />
    </View>
  );
}

React Query

// components/UserProfile.tsx
import { useGetUser } from '@/api/generated';
import { View, Text, ActivityIndicator } from 'react-native';

export function UserProfile({ userId }: { userId: string }) {
  const { data, error, isLoading, refetch } = useGetUser(userId);

  if (isLoading) return <ActivityIndicator />;
  if (error) return <Text>Error: {error.message}</Text>;

  return (
    <View>
      <Text>{data?.name}</Text>
      <Button onPress={() => refetch()} title="Refresh" />
    </View>
  );
}

6.2 Mutation(データ作成・更新)

SWR

import { useCreateUser, useGetUsers } from '@/api/generated';
import useSWR, { useSWRConfig } from 'swr';

function CreateUserForm() {
  const { trigger, isMutating, error } = useCreateUser();
  const { mutate } = useSWRConfig();

  const handleSubmit = async (formData: CreateUserBody) => {
    try {
      await trigger(formData);
      // 手動でキャッシュを無効化
      mutate('/api/users');
    } catch (e) {
      console.error(e);
    }
  };

  return (
    <View>
      {/* フォーム */}
      <Button
        onPress={() => handleSubmit({ name: 'John' })}
        disabled={isMutating}
        title={isMutating ? 'Creating...' : 'Create User'}
      />
    </View>
  );
}

React Query

import { useCreateUser, useGetUsers, getGetUsersQueryKey } from '@/api/generated';
import { useQueryClient } from '@tanstack/react-query';

function CreateUserForm() {
  const queryClient = useQueryClient();

  const { mutate, isPending, error } = useCreateUser({
    onSuccess: () => {
      // クエリキーで無効化(型安全)
      queryClient.invalidateQueries({ queryKey: getGetUsersQueryKey() });
    },
  });

  const handleSubmit = (formData: CreateUserBody) => {
    mutate(formData);
  };

  return (
    <View>
      {/* フォーム */}
      <Button
        onPress={() => handleSubmit({ name: 'John' })}
        disabled={isPending}
        title={isPending ? 'Creating...' : 'Create User'}
      />
    </View>
  );
}

6.3 Optimistic Updates(楽観的更新)

SWR

import { useUpdateUser, useGetUser } from '@/api/generated';
import useSWR, { useSWRConfig } from 'swr';

function EditUserForm({ userId }: { userId: string }) {
  const { data: user, mutate: mutateUser } = useGetUser(userId);
  const { trigger } = useUpdateUser();

  const handleUpdate = async (newName: string) => {
    // 楽観的更新
    const optimisticData = { ...user!, name: newName };

    try {
      await mutateUser(
        async () => {
          // 実際の API 呼び出し
          return await trigger({ userId, data: { name: newName } });
        },
        {
          optimisticData,
          rollbackOnError: true,
          revalidate: false,
        }
      );
    } catch (e) {
      // エラー時は自動的にロールバック
      Alert.alert('Error', 'Failed to update');
    }
  };

  return (/* ... */);
}

React Query(より充実した機能)

import { useUpdateUser, useGetUser, getGetUserQueryKey } from '@/api/generated';
import { useQueryClient } from '@tanstack/react-query';

function EditUserForm({ userId }: { userId: string }) {
  const queryClient = useQueryClient();
  const { data: user } = useGetUser(userId);

  const { mutate } = useUpdateUser({
    // Mutation 開始前
    onMutate: async ({ userId, data }) => {
      // 進行中のクエリをキャンセル
      await queryClient.cancelQueries({ queryKey: getGetUserQueryKey(userId) });

      // 前の値を保存
      const previousUser = queryClient.getQueryData(getGetUserQueryKey(userId));

      // 楽観的更新
      queryClient.setQueryData(getGetUserQueryKey(userId), (old: User) => ({
        ...old,
        ...data,
      }));

      // コンテキストにロールバック用データを保存
      return { previousUser };
    },

    // エラー時のロールバック
    onError: (err, variables, context) => {
      if (context?.previousUser) {
        queryClient.setQueryData(
          getGetUserQueryKey(variables.userId),
          context.previousUser
        );
      }
      Alert.alert('Error', 'Failed to update');
    },

    // 成功・失敗どちらでも再検証
    onSettled: (data, error, variables) => {
      queryClient.invalidateQueries({ queryKey: getGetUserQueryKey(variables.userId) });
    },
  });

  return (/* ... */);
}

6.4 無限スクロール(Infinite Loading)

SWR

import useSWRInfinite from 'swr/infinite';
import { useSWRNativeRevalidate } from '@nandorojo/swr-react-native';

function UserList() {
  const getKey = (pageIndex: number, previousPageData: User[] | null) => {
    if (previousPageData && previousPageData.length === 0) return null;
    return `/api/users?page=${pageIndex}&limit=20`;
  };

  const {
    data,
    size,
    setSize,
    isLoading,
    isValidating,
    mutate,
  } = useSWRInfinite<User[]>(getKey, fetcher);

  // React Native 対応
  useSWRNativeRevalidate({ mutate });

  const users = data ? data.flat() : [];
  const isLoadingMore = isLoading || (size > 0 && data && typeof data[size - 1] === 'undefined');
  const isEmpty = data?.[0]?.length === 0;
  const isReachingEnd = isEmpty || (data && data[data.length - 1]?.length < 20);

  return (
    <FlatList
      data={users}
      renderItem={({ item }) => <UserItem user={item} />}
      keyExtractor={(item) => item.id}
      onEndReached={() => {
        if (!isReachingEnd && !isLoadingMore) {
          setSize(size + 1);
        }
      }}
      onEndReachedThreshold={0.5}
      ListFooterComponent={isLoadingMore ? <ActivityIndicator /> : null}
    />
  );
}

React Query

import { useInfiniteQuery } from '@tanstack/react-query';
import { useGetUsersInfinite, getGetUsersInfiniteQueryKey } from '@/api/generated';

function UserList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
    refetch,
  } = useGetUsersInfinite(
    { limit: 20 },
    {
      getNextPageParam: (lastPage, allPages) => {
        return lastPage.length === 20 ? allPages.length : undefined;
      },
    }
  );

  const users = data?.pages.flat() ?? [];

  return (
    <FlatList
      data={users}
      renderItem={({ item }) => <UserItem user={item} />}
      keyExtractor={(item) => item.id}
      onEndReached={() => {
        if (hasNextPage && !isFetchingNextPage) {
          fetchNextPage();
        }
      }}
      onEndReachedThreshold={0.5}
      refreshControl={
        <RefreshControl refreshing={isLoading} onRefresh={refetch} />
      }
      ListFooterComponent={isFetchingNextPage ? <ActivityIndicator /> : null}
    />
  );
}

6.5 条件付きフェッチ

SWR

// null を渡すとフェッチしない
const { data } = useGetUser(isLoggedIn ? userId : null);

// または options で制御
const { data } = useGetUser(userId, {
  // 条件を満たさない場合はフェッチしない
  isPaused: () => !isLoggedIn,
});

React Query

// enabled オプションで制御
const { data } = useGetUser(userId, {
  enabled: isLoggedIn && !!userId,
});

7. 移行時のチェックリスト

SWR を React Native で継続使用する場合

  • [ ] @nandorojo/swr-react-native または手動設定で revalidateOnFocus / revalidateOnReconnect を有効化
  • [ ] キャッシュ永続化の実装(AsyncStorage または MMKV)
  • [ ] Orval 設定で useSWRInfinite / useSWRMutation を有効化
  • [ ] カスタムフェッチャーで React Native 環境対応(BASE_URL 等)
  • [ ] Android エミュレータlocalhost 問題に対応(10.0.2.2

SWR → React Query に移行する場合

SWR React Query 備考
useSWR useQuery ほぼ同じ
useSWRMutation useMutation 返り値が異なる
useSWRInfinite useInfiniteQuery API が異なる
mutate(key) invalidateQueries({ queryKey }) キー指定方法が異なる
SWRConfig QueryClientProvider Provider の構成
isLoading isLoading 同じ
isValidating isFetching 名前が異なる
error error 同じ

移行時の追加実装

機能 SWR での対応 React Query での対応
DevTools 自前で実装 @tanstack/react-query-devtools
キャッシュ永続化 自前実装 or swr-sync-native-storage @tanstack/query-async-storage-persister
React Native Focus @nandorojo/swr-react-native 設定不要(ほぼ動作)
Optimistic Update mutate の options onMutate + onError
Query 無効化 mutate(key) invalidateQueries

参考資料

公式ドキュメント

ライブラリ・パッケージ

比較記事

React Native 固有


まとめ

観点 SWR React Query
React Native 追加設定 必須(focus/reconnect) ほぼ不要
キャッシュ永続化 自前実装 or 外部パッケージ 公式プラグイン
Orval サポート
DevTools
Optimistic Update 基本的 充実
バンドルサイズ 小さい(~5KB) 大きめ(~16KB)
学習コスト 低い やや高い

結論

  • SWR を継続 → React Native 固有の設定とキャッシュ永続化の実装が必要。軽量さを重視する場合に選択。
  • React Query に移行 → React Native での追加設定が少なく、公式の永続化プラグインや DevTools が利用可能。機能が豊富で良い。サクッと始めたいならこちらが良いかも

Orval はどちらもサポートしているため、プロジェクトの要件に応じて選択するのがよ誘う。

ClaudeのSkillが増えると「組織のサイロ化」と同じ問題が起きるのでは・・・?

Claude Skillが増えると「組織のサイロ化」と同じ問題が起きる——Skill最適化フレームワークの設計を考える

TL;DR

  • Claude Code/claude.aiのSkill機能を使い込んでいくと、Skill同士の競合・重複・境界曖昧化が発生する
  • これは企業における「組織のサイロ化」と本質的に同じ構造を持つと仮説を立てた
  • SOLID原則とMECE分類を応用したSkill最適化フレームワークを設計した

問題の発見:Skillが増えると何が起きるか

Claude CodeやClaude.aiでは、Skillという仕組みで専門知識やワークフローをモジュール化できる。 ドキュメント作成、コードレビュー、特定ドメインの分析など、繰り返し使うパターンをSkillとして定義しておくと、Claudeがそれを参照して精度の高い出力を返してくれる。

最初は便利だ。必要に応じてSkillを追加していけばいい。 しかし、Skillが10個、20個と増えていくと、奇妙な現象が起き始めてきた。

  • 同じクエリに対して複数のSkillが反応しそうになる
  • 似たような処理ロジックが複数のSkillに散在している
  • どのSkillを使うべきか、自分でも迷う
  • 新しいSkillを作ろうとすると、既存Skillとの境界が曖昧になる

これ・・・。どこかで見た光景・・・。

組織のサイロ化との類似性

企業が成長すると、専門チームが分岐していく。 マーケティング、セールス、エンジニアリング、カスタマーサクセス。最初は明確だった境界が、事業の複雑化とともに曖昧になる。

  • マーケとセールスの両方が「リード獲得」を担当している
  • 同じ顧客データを複数のチームが別々に管理している
  • 新しいプロジェクトをどのチームが担当すべきか誰も決められない
  • チーム間の連携コストが肥大化する

Skillのサイロ化も、これと全く同じ構造を持っている。

組織の問題 Skillの問題
責任範囲の重複 トリガー条件の競合
同じ業務を複数チームが実施 同じロジックが複数Skillに存在
どのチームに依頼すべきか不明 どのSkillが発火すべきか曖昧
チーム間の暗黙の依存関係 Skill間の暗黙の前提

組織論では、この問題に対して様々なフレームワークが提案されてきた。 であれば、それをSkillの世界に輸入できるはず。

設計原則:SOLID + MECE

SOLIDの適用

ソフトウェア設計のSOLID原則は、そのままSkill設計に適用できる。

Single Responsibility(単一責任) 1つのSkillは1つの明確なドメインだけを担当する。「ドキュメント作成と分析とレビュー」を1つのSkillに詰め込まない。

Open/Closed(開放閉鎖) 既存Skillを修正せずに、新しいSkillで機能を拡張できる設計にする。

Liskov Substitution(リスコフの置換) 汎用Skillと特化Skillがある場合、特化Skillは汎用Skillの期待を裏切らない。

Interface Segregation(インターフェース分離) トリガー条件を細分化し、不要なSkillが発火しないようにする。

Dependency Inversion(依存性逆転) 共通機能は抽象的な「ベースSkill」に切り出し、特化Skillはそれに依存する。

MECEの適用

トリガー条件はMutually Exclusive, Collectively Exhaustiveとなるのが良いとされているらしい。であればそれに従って作ってみるのが良さそう。

  • Mutually Exclusive: 1つのクエリに対して発火するSkillは1つだけ
  • Collectively Exhaustive: 対象ドメインのクエリは必ずいずれかのSkillでカバー

これが崩れると、競合(複数発火)か漏れ(どれも発火しない)が起きる。

検出すべき4つのアンチパターン

Skillのサイロ化は、4つのパターンとして現れる。

┌─────────────────────────────────────────────────────────────┐
│                    Skill Anti-Patterns                      │
├─────────────────┬───────────────────────────────────────────┤
│ Trigger         │ 同じクエリで複数Skillが発火しうる        │
│ Conflict        │ → トリガー条件の細分化 or 優先度設定     │
├─────────────────┼───────────────────────────────────────────┤
│ Functional      │ 類似ロジックが複数Skillに散在            │
│ Duplication     │ → 共通Skillへの抽出 or 統合              │
├─────────────────┼───────────────────────────────────────────┤
│ Boundary        │ どのSkillを使うべきか不明確              │
│ Ambiguity       │ → 責任範囲の明文化                       │
├─────────────────┼───────────────────────────────────────────┤
│ Implicit        │ Skill AがSkill Bの知識を暗黙に前提       │
│ Dependency      │ → 明示的な依存宣言 or 統合               │
└─────────────────┴───────────────────────────────────────────┘

最適化パターン

検出した問題に対して、5つの最適化パターンを適用する。

1. Merge(統合)

機能重複が70%以上のSkillは統合を検討する。組織でいえば、重複する2つのチームを1つに再編するイメージ。

Before:  [report-generator] [document-creator]
              ↓ 70%重複
After:   [document-skill](統合)

2. Split(分割)

責任過多のSkillは分割する。descriptionが200文字を超えていたり、「〜と〜と〜」のように複数目的が接続されていたら危険信号。

Before:  [api-skill](設計・実装・テスト・ドキュメント)
              ↓
After:   [api-design] [api-impl] [api-test] [api-docs]

3. Extract(抽出)

複数Skillに同じロジックがあれば、共通Skillとして抽出する。DRY原則の適用。

Before:  [skill-A]──共通ロジック
         [skill-B]──共通ロジック(コピペ)
         [skill-C]──共通ロジック(コピペ)
              ↓
After:   [base-skill]──共通ロジック
              ↑
         [skill-A] [skill-B] [skill-C]

4. Hierarchize(階層化)

汎用Skillと特化Skillが競合している場合、明確な階層関係を定義する。

Level 0:  [skill-router](振り分け)
              ↓
Level 1:  [document-skill](汎用)
              ↓
Level 2:  [legal-doc-skill](法務特化)
              ↓
Level 3:  [jp-legal-doc-skill](日本法務特化)

5. Route(ルーティング)

Skill数が多くなりすぎた場合、メタSkill(ルーター)を導入してディスパッチする。

全体アーキテクチャ

これらを組み合わせた全体像は以下のようになる。

                         ┌─────────────────┐
                         │  User Query     │
                         └────────┬────────┘
                                  │
                                  ▼
                         ┌─────────────────┐
                         │  Skill Router   │ ← メタSkill(必要に応じて)
                         └────────┬────────┘
                                  │
              ┌───────────────────┼───────────────────┐
              │                   │                   │
              ▼                   ▼                   ▼
     ┌─────────────┐     ┌─────────────┐     ┌─────────────┐
     │ Domain A    │     │ Domain B    │     │ Domain C    │
     │ (Generic)   │     │ (Generic)   │     │ (Generic)   │
     └──────┬──────┘     └──────┬──────┘     └─────────────┘
            │                   │
      ┌─────┴─────┐       ┌─────┴─────┐
      ▼           ▼       ▼           ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ A-spec-1 │ │ A-spec-2 │ │ B-spec-1 │  ← 特化Skill
└──────────┘ └──────────┘ └──────────┘
      │           │             │
      └───────────┴──────┬──────┘
                         ▼
              ┌─────────────────┐
              │   Base Skill    │ ← 共通ロジック
              │ (共通ワークフロー) │
              └─────────────────┘

ポイント: - 上位ほど汎用、下位ほど特化 - 横方向はMECE(相互排他・網羅的) - 共通ロジックは最下層のBase Skillに集約 - ルーターは必要に応じて導入(Skill数が少なければ不要)

健全性チェックリスト

Skillを追加・修正する際は、以下をチェックする。

  • [ ] このSkillのdescriptionは1文で要約できるか?
  • [ ] トリガー条件は既存Skillと重複していないか?
  • [ ] 既存Skillからコピペしたロジックはないか?
  • [ ] 暗黙に前提としている他のSkillはないか?
  • [ ] 命名規則は一貫しているか?

1つでも「いいえ」があれば、最適化の余地がある。

まとめ

「専門性の分化」という同じ構造に起因する必然的な現象ととらえ、Skillが増えすぎると組織と同じサイロ化問題を引き起こすという仮説にたって進めてみた。 そうとらえると、対処法も組織論から輸入できる。 SOLID原則で責任を明確化し、MECE分類で境界を定義し、定期的にリファクタリングする。 LLMのSkillやエージェントが増えていくこれからの時代、「AIの組織設計」という視点がますます重要になるかも。

React Native + Expo におけるiOSアプリの実機デバッグ

iOS 実機デバッグガイド

macOSに接続されたiOS実機でReact Native(Expo)アプリをデバッグするための手順をまとめておく。 このまとめのさいにやった際には、新アーキテクチャを使った実装で試したため、一部違うものがあるなどはあるかもしれない。


前提条件

パッケージマネージャーについて

お使いの環境に合わせてコマンドを読み替えること。

パッケージマネージャー コマンド例
npm npm run start / npx expo run:ios
yarn yarn start / yarn expo run:ios
pnpm pnpm start / pnpm exec expo run:ios
bun bun start / bun expo run:ios

以降のドキュメントでは npx を使用した例を記載する。


1. デバイスの確認

接続されているデバイスの一覧表示

# 詳細なデバイス一覧(シミュレーター含む)
xcrun xctrace list devices

# 接続されたデバイスのみ表示
xcrun devicectl list devices

出力例:

Name                   Identifier                             State       Model
UserのiPhone           XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX   connected   iPhone 15 Pro

バイスIDの取得

後続のコマンドで使用するため、デバイスIdentifier をメモする。

以降は <DEVICE_ID> と表記。


2. アプリの状態確認

インストール済みアプリの確認

xcrun devicectl device info apps --device "<DEVICE_ID>" | grep -i "<アプリ名>"

例:

xcrun devicectl device info apps --device "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" | grep -i "myapp"

出力例:

MyApp   com.example.myapp   1.0.0     0.0.1

実行中のプロセス確認

xcrun devicectl device info processes --device "<DEVICE_ID>" | grep -i "<アプリ名>"

出力例:

13623   /private/var/containers/Bundle/Application/.../MyApp.app/MyApp

PID(プロセスID)は最初の数字(例:13623)。


3. アプリの操作

アプリの強制終了

xcrun devicectl device process terminate --device "<DEVICE_ID>" --pid <PID>

例:

xcrun devicectl device process terminate --device "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" --pid 13623

アプリの起動

# 通常起動
xcrun devicectl device process launch --device "<DEVICE_ID>" <BUNDLE_ID>

# コンソール出力付きで起動(ネイティブログを表示)
xcrun devicectl device process launch --device "<DEVICE_ID>" --console <BUNDLE_ID>

例:

xcrun devicectl device process launch --device "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" --console com.example.myapp

4. ログの確認

方法1: Console.app でリアルタイムログを確認(推奨)

  1. Console.app を開く:    bash    open -a Console    

  2. 左サイドバーでデバイス名を選択

  3. 右上の検索ボックスでアプリ名またはバンドルIDでフィルター

  4. 「開始」ボタンを押してログストリームを開始

  5. アプリを操作するとログがリアルタイムで表示される

方法2: Xcode でデバイスログを確認

  1. Xcode を開く
  2. WindowDevices and Simulators
  3. 対象デバイスを選択
  4. View Device Logs をクリック

クラッシュログの確認

xcrun devicectl device info crashlogs --device "<DEVICE_ID>" | grep -i "<アプリ名>"

5. ローカルビルドと実機インストール

開発ビルドを実機にインストール

cd <プロジェクトディレクトリ>

# Metro サーバーを起動(別ターミナルで実行)
npx expo start

# 実機にビルド&インストール(別ターミナルで実行)
npx expo run:ios --device "<DEVICE_ID>"

注意: --device オプションにはデバイスIDまたはデバイス名を指定できる。

ビルドのみ(インストールなし)

npx expo run:ios --device "<DEVICE_ID>" --no-install

ビルドに時間がかかる場合

初回ビルドは10〜20分程度かかることがある。 以下のような出力が表示される

› Compiling Pods/ReactCodegen » ...
› Linking MyApp » MyApp
› Signing MyApp » MyApp.app
› Build Succeeded
› Installing ...
✔ Complete 100%

6. Metro サーバーでのデバッグ

Metro サーバーの起動

cd <プロジェクトディレクトリ>
npx expo start

サーバーが起動すると http://localhost:8081 でアクセス可能になる。

Mac の IP アドレスを確認

実機から接続するために必要

# Wi-Fi接続の場合
ipconfig getifaddr en0

# 有線接続の場合
ipconfig getifaddr en1

実機からの接続手順

  1. 実機でアプリを起動
  2. Dev Client メニューが表示されたら、開発サーバーを選択    - または「Enter URL manually」を選択
  3. http://<MAC_IP>:8081 を入力して接続

注意: MaciPhone が同じネットワークに接続されている必要がある。

Metro ログの見方

Metro サーバーのターミナルにリアルタイムでJavaScriptのログが表示される

プレフィックス 意味
LOG console.log() の出力
WARN console.warn() の出力
ERROR console.error() の出力

7. よくある問題と解決方法

スプラッシュスクリーンでフリーズする

考えられる原因 - 起動時の非同期処理(通知権限リクエストなど)がUIをブロック - ネットワークリクエストのタイムアウト待ち - 初期化処理での例外

解決方法 1. Console.app でログを確認し、どこで止まっているか特定 2. 権限リクエストなどのブロッキング処理を起動後に遅延実行するよう変更 3. try-catch で例外をキャッチしてログ出力

バイスが認識されない

解決方法 1. USB ケーブルを再接続 2. デバイスで「このコンピュータを信頼しますか?」に許可 3. Xcode を開き、デバイスを一度選択 4. デバイスを再起動 5. Mac を再起動

Metro サーバーに接続できない

解決方法 1. MaciPhone が同じ Wi-Fi ネットワークにあることを確認 2. ファイアウォール設定で 8081 ポートを許可 3. キャッシュをクリアして再起動:    bash    npx expo start --clear    

ビルドエラー: Prebuild failed

考えられる原因 - 画像ファイルの破損(CRCエラー) - node_modules の不整合 - キャッシュの破損

解決方法

# キャッシュとnode_modulesをクリア
rm -rf node_modules
rm -rf .expo
npm install  # または yarn / pnpm install / bun install

# prebuildを再実行
npx expo prebuild --clean

8. EAS Build でのリモートビルド(Expo プロジェクトの場合)

キャッシュをクリアしてビルド

npx eas build --platform ios --clear-cache

特定のプロファイルでビルド

# 開発用(実機)
npx eas build --platform ios --profile development

# シミュレーター用
npx eas build --platform ios --profile development-simulator

# 本番用
npx eas build --platform ios --profile production

クイックリファレンス

devicectl コマンド

操作 コマンド
バイス一覧 xcrun devicectl list devices
アプリ一覧 xcrun devicectl device info apps --device "<ID>"
プロセス一覧 xcrun devicectl device info processes --device "<ID>"
アプリ終了 xcrun devicectl device process terminate --device "<ID>" --pid <PID>
アプリ起動 xcrun devicectl device process launch --device "<ID>" <BUNDLE_ID>
アプリ起動(ログ付き) xcrun devicectl device process launch --device "<ID>" --console <BUNDLE_ID>

Expo コマンド

操作 コマンド
Metro 起動 npx expo start
Metro 起動(キャッシュクリア) npx expo start --clear
ローカルビルド&インストール npx expo run:ios --device "<ID>"
Prebuild(クリーン) npx expo prebuild --clean

その他

操作 コマンド
Mac IP 確認(Wi-Fi ipconfig getifaddr en0
Console.app を開く open -a Console
Xcode を開く open -a Xcode

参考リンク

Mismatch between JavaScript part and native part of Worklets が出た原因と対処法

bun + Expo Dev Client 環境で react-native-reanimated を使っていたらハマった話


結論(先に要点)

このエラーは react-native-reanimated が内部で依存している react-native-worklets のJavaScript 側と Native 側のバージョンが食い違っていることが原因。

 

特に Expo Dev Client + bun 環境では、「過去にビルドしたネイティブが端末に残る」→「JS だけ更新される」 という状態が非常に起きやすくなる。


発生したエラー

Uncaught Error
[Worklets] Mismatch between JavaScript part and native part of Worklets (0.5.1 vs 0.6.1).

この表示は以下を意味します。

JS と Native が別バージョンだった。最初意味わからんかった・・・。


前提環境

  • bun

  • Expo + expo-dev-client

  • react-native-reanimated 利用


まず確認したこと(依存の混在チェック)

bun pm ls react-native-worklets

結果:

[email protected]

👉 JS 側は 0.5.1 のみ
→ 依存の二重インストールではない


本当の原因

原因は ネイティブアプリ側が 0.6.1 でビルドされたまま残っていたこと。

領域 状態
JavaScript node_modules の 0.5.1
Native 過去にビルドした 0.6.1

🔍 react-native-reanimated と worklets の関係(重要)

ここを理解すると、このエラーが「なぜ出るか」が一発で腑に落ちます。

全体構造(概念図)

┌────────────────────────────┐
│  React Native JavaScript                 │
│                                           │
│  react-native-reanimated                  │
│      │                                    │
│      └─ uses ─────────────┐       │
│                                          │            │
│  react-native-worklets                   │            │
│   (JS runtime code)                      │            │
└─────────────▲──────────────┘
                            │
                            │ version must match
                            │
┌─────────────┴──────────────┐
│   Native (iOS / Android)                               │
│                                                        │
│  react-native-worklets                                 │
│   (C++ / Obj-C / Java)                                 │
│                                                        │
│  UI thread / JSI runtime                               │
└────────────────────────────┘

ポイント

  • react-native-reanimated は直接 UI スレッドを触らない

  • 内部で react-native-worklets を使っている

  • worklets は

  • この2つのバージョンが完全一致していないと即クラッシュ


なぜ Expo Dev Client で特に起きやすいのか

Expo Dev Client は仕組み上こうなっている。

  • ネイティブライブラリ
    ビルド時に固定

  • JavaScript
    あとから自由に差し替え

そのため、

  1. reanimated / worklets をアップデート

  2. JS だけ入れ替わる

  3. 端末のネイティブは古いまま

という状態が簡単に発生する。

 

「Metro が正しく動いているのにクラッシュする」典型例。

 

つらみ・・・。


解決方法①(最短・おすすめ)

Native に合わせて JS を揃える

Native が 0.6.1 だったため、JS も合わせる必要がある。

bun add [email protected]
bun install

その後、

  1. Simulator / 実機からアプリを削除

  2. キャッシュクリア起動

bunx expo start -c

実機検証の場合はこれがBuildになる可能性はあるので注意が必要。


解決方法②(JS を固定したい場合)

JS を 0.5.1 のまま使いたい場合は
Native を 0.5.1 で再ビルドする必要がある。

iOS

cd ios
rm -rf Pods Podfile.lock
bunx pod install
cd ..

Android

cd android
./gradlew clean
cd ..

その後:

  • アプリ削除

  • Dev Client 再ビルド

  • bunx expo start -c


ネイティブ側のバージョン確認(補足)

cat ios/Podfile.lock | rg worklets

ここに書かれているのが


「今端末で動いている Native 側の真のバージョン」


学び・再発防止

  • reanimated は worklets 依存を強く持つ

  • worklets は JS / Native の完全一致が必須

  • Expo Dev Client では
    「依存を変えたら、端末のアプリ削除 + 再ビルド」


まとめ

  • このエラーは 依存関係ではなくビルド状態の問題

  • bun + Expo Dev Client 環境では特に起きやすい

  • Native と JS を必ず同じバージョンに揃える

フロントエンドカンファレンス関西2025に参加してきた

フロントエンドカンファレンス関西2025 に参加してきました。

結論から言うと、

 

とてもよかった・・・!

関西の技術コミュニティは、関東とはまた熱量のベクトルが違うと感じました。

 

当日の自分のポスト(#fec_kansai)を振り返りつつ、印象深かったセッションや会場の雰囲気、懇親会での出来事などをまとめていきます。


Sakito さんの基調講演:フロントエンドの“これから”に向き合う時

今年の基調講演は Sakito さん。これが本当に良かった。

speakerdeck.com

 

とくに心に残ったのは、↓これです。

普段スタートアップでスピード重視の開発をしている自分には、めちゃくちゃ刺さりました。前職で無意識にやってたことを言語化していただいたなーという気持ちです。今年の #fec_kansai の「軸」になる講演だったと感じています。


会場の雰囲気:熱と距離の近さが両立するイベント

関西のカンファレンスは、“ゆるさ” と “技術のガチさ” のバランスがよいですよね。

朝からテンション高めの参加者が多く、知っている顔ぶれとの再会もありつつ、新しい出会いもありつつ、「コミュニティの密度」が高かったです。別地域であるにも関わらず、初参加の方も多い印象で、技術者が育つ土壌としてもめちゃくちゃ良いなと感じました。

個人的には、会場で 「ジンくんだっ!」 とテンション上がったり、「きゃーまぐろさん!」 と言ったり、知り合いとの再会も楽しめてよかったです。


セッションで得た学び

● yuta-ike さんの Figma スライドがみやすさと練り上げがすごかった

ポストにも書いた通り、

まじで練り上げられててすげぇな……

Figma の Smart Animate を駆使して

「設定なしでView Transitionっぽいスライド遷移を実現」

していたのが圧巻。(あとからきいたら、Figmaスライドですらなかったことはびっくり)

懇親会でも「たぶんFigmaだろうなー」と話をしていて、Figma でここまでできる時代なんだ…と改めて実感しました。

ロングバージョンの資料も拝見しましたが、

「みるしかない…w」

とそのままツイートしてしまうくらいの惹きつけ力でした。

ソースマップはフロントエンド開発では必ず利用されていますが、意外と “深く理解している人が少ない” 領域。今回の発表でちゃんと仕組みこうなってるんだ・・・。とおもったので、フロントエンドを前より書くようになったので、ちゃんとやったほうが良いなと感じました。


● 物理演算の「ぷにゃっ」は完全に沼

ガチの物理演算が「ぷにゃっ」のためだけに行われてるの好きwww

この投稿にすべてが詰まっているのですが、
フロントエンドって“遊び”や“遊び心”から生まれる技術ってたくさんあるんですよね。

(なんというかそのためだけにそこまでする???みたいなのが好きです)

 

技術を正しく「行使」されているなという印象で、このような技術の使いこなし方を自分ももっとしていきたいと考えています。


まさかの登壇者Tシャツゲット(コスプレします)

今回、登壇者Tシャツじゃんけんがあったのですが、私……

しれっとゲットしました…!
これで登壇者コスプレします!w

完全に沸きましたw
来年は本当に登壇者側にいけるよう、もっと技術アウトプットを増やしていきたい。

 


懇親会での学びと交流

懇親会、ほんとに濃かった。

  • Figmaスライド制作の裏側

  • 技術共有カルチャーの話

  • 関西のコミュニティの作り方

などなど、技術の話だけでなく “コミュニティ作り” に関する知見がめちゃくちゃ多い のがフロントエンドカンファレンス関西の魅力。

話してくださった皆さん、本当にありがとうございました。

また関西のイベント参加しにきます!


一番感じたこと:技術者の“原点”に戻れるイベント

技術の最先端を追うセッションもあるけれど、同じくらい “楽しさ” や “遊び心” が尊重されている。

  • 技術を使って表現する

  • 面白いことのために技術を磨く

  • コミュニティで学び合う

そんな姿を間近で見られるのが何よりの体験でした。


最後に:来年は登壇したい

今年は参加者として全力で楽しませてもらいましたが、Tシャツも手に入れたし(?)、
来年は登壇側に回れるよう、技術・発信の両面でもっと積み上げていきます。

フロントエンドカンファレンス関西、本当に最高でした。運営・登壇者・スポンサー・参加者の皆さん、ありがとうございました!

また関西に行きます!


追伸:感謝

実は今回懇親会参加予定はなかったのですが、 @941さんから懇親会チケットを譲っていただきました!本当にありがとうございました!!

次回はナンバーズTシャツを着て技術カンファレンス参加します・・・!w