1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【入門】TCAでGitHubのリポジトリ検索アプリを作ってみて感じたこと【SwiftUI + TCA】

Last updated at Posted at 2025-07-10

【入門】TCAでGitHubのリポジトリ検索アプリを作ってみて感じたこと

こんにちは、Keisuke Yamagishi です。今回は The Composable Architecture(TCA) を使って、GitHubのリポジトリ検索アプリを作ってみました。TCAは状態管理をスケーラブルに行いたいときに非常に便利なアーキテクチャで、最近のSwiftUI開発でも注目を集めています。

この記事では、TCAを使った簡単なサンプルアプリの構成やコードを紹介します。


🧱 アプリの概要

このアプリは以下のような構成です:

  • SwiftUI + TCA でアプリを構築
  • テキストフィールドに検索語を入力
  • GitHubの Search Repositories API を叩いて検索
  • 結果をリスト表示

🛠 使用技術

技術 バージョン / 説明
Swift 5.9
SwiftUI -
TCA v1.0+
GitHub API REST API (Search Repositories)

🔧 TCA構成

今回は以下のようなディレクトリ構成にしました:

TCASample/
└── Feature
    ├── Api
    │   ├── DependencyValues+.swift
    │   ├── GitHubClient.swift
    │   ├── GitHubClient+.swift
    │   └── Response
    │       └── Repository.swift
    ├── App
    │   ├── ContentView.swift
    │   └── TCASampleApp.swift
    └── Reducer
        └── GithubSearchFeature.swift

API層、View層、状態管理(Reducer)をそれぞれ分けることで、見通しの良い構成にしています。


👀 View周り

TCASampleApp.swift

import SwiftUI
import ComposableArchitecture

@main
struct TCASampleApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView(
                store: Store(initialState: GitHubSearchFeature.State()) {
                    GitHubSearchFeature()
                }
            )
        }
    }
}

TCAのStoreをルートに渡す形です。


ContentView.swift

import SwiftUI
import ComposableArchitecture

struct ContentView: View {
    let store: StoreOf<GitHubSearchFeature>

    var body: some View {
        WithViewStore(store, observe: { $0 }) { viewStore in
            NavigationView {
                VStack {
                    HStack {
                        TextField("検索ワード", text: viewStore.binding(
                            get: \.query,
                            send: GitHubSearchFeature.Action.setQuery
                        ))
                        .textFieldStyle(PlainTextFieldStyle())
                        .padding()

                        if !viewStore.query.isEmpty {
                            Button(action: {
                                viewStore.send(.setQuery(""))
                            }) {
                                Image(systemName: "xmark.circle.fill")
                                    .foregroundColor(.gray)
                            }
                        }

                        Button("検索") {
                            viewStore.send(.searchButtonTapped)
                        }
                        .padding()
                    }

                    if viewStore.isLoading {
                        ProgressView()
                    }

                    List(viewStore.results) { repository in
                        Text(repository.fullName)
                    }
                    .listStyle(PlainListStyle())
                }
                .navigationTitle("GitHub")
            }
        }
    }
}
  • 入力とバインディング
  • 「検索」ボタンでAPI呼び出し
  • 検索中はProgressViewを表示
  • 結果をListで表示

⚙️ Reducer

GitHubSearchFeature.swift

import ComposableArchitecture
import Foundation

@Reducer
struct GitHubSearchFeature {
    struct State: Equatable {
        var query: String = ""
        var results: [Repository] = []
        var isLoading: Bool = false
    }

    enum Action: Equatable {
        case setQuery(String)
        case searchButtonTapped
        case searchResponse(Result<[Repository], GitHubClient.Error>)
    }

    @Dependency(\.githubClient) var githubClient

    func reduce(into state: inout State, action: Action) -> Effect<Action> {
        switch action {
        case let .setQuery(query):
            state.query = query
            return .none

        case .searchButtonTapped:
            state.isLoading = true
            let query = state.query
            return .run { send in
                await send(
                    .searchResponse(
                        Result {
                            try await githubClient.search(query)
                        }
                        .mapError { $0 as? GitHubClient.Error ?? .networkError }
                    )
                )
            }

        case let .searchResponse(.success(repos)):
            state.results = repos
            state.isLoading = false
            return .none

        case .searchResponse(.failure):
            state.results = []
            state.isLoading = false
            return .none
        }
    }
}

🌐 API層

DependencyValues+.swift

import ComposableArchitecture

extension DependencyValues {
    var githubClient: GitHubClient {
        get { self[GitHubClient.self] }
        set { self[GitHubClient.self] = newValue }
    }
}

GitHubClient.swift

import ComposableArchitecture

struct GitHubClient {
    var search: (String) async throws -> [Repository]

    enum Error: Swift.Error, Equatable {
        case decodingError
        case networkError
    }
}

GitHubClient+.swift

import Foundation
import ComposableArchitecture

extension GitHubClient: DependencyKey {
    static let liveValue = GitHubClient { query in
        guard
            let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
            let url = URL(string: "https://api.github.com/search/repositories?q=\(encoded)")
        else {
            throw Error.networkError
        }

        let (data, _) = try await URLSession.shared.data(from: url)

        guard let result = try? JSONDecoder().decode(SearchResult.self, from: data) else {
            throw Error.decodingError
        }

        return result.items
    }

    struct SearchResult: Decodable {
        let items: [Repository]
    }
}

Repository.swift

struct Repository: Decodable, Equatable, Identifiable {
    let id: Int
    let fullName: String

    enum CodingKeys: String, CodingKey {
        case id
        case fullName = "full_name"
    }
}

📝 最後に

TCAに触れるのが初めてでも、APIコールやUIバインディングの流れが理解しやすく、Reduxライクなアーキテクチャをしっかりと体験できます。

今後は、エラーハンドリングやページネーション、ユニットテストの導入なども加えていきたいと思っています。

ご意見やアドバイスがあれば、ぜひコメントでいただけるとうれしいです!


1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?