【入門】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ライクなアーキテクチャをしっかりと体験できます。
今後は、エラーハンドリングやページネーション、ユニットテストの導入なども加えていきたいと思っています。
ご意見やアドバイスがあれば、ぜひコメントでいただけるとうれしいです!