Slide 1

Slide 1 text

USZ4XJGU5PLZP4UVEFOU$MVC 5$"ͷΑ͏ͳΞʔΩςΫνϟΛ ࡞ͬͯΈͨ࿩ ΓΎ͏ !SZV@IV

Slide 2

Slide 2 text

ࣗݾ঺հ w ΓΎ͏ !SZV@IV w ๏੓େֶిؾిࢠ޻ֶՊ# w ൒೥͘Β͍4XJGU6*5$"ͰษڧܥͷΞϓϦΛ։ൃͯ͠Δ ະϦϦʔε w $-*πʔϧ΍ϥΠϒϥϦΛ࡞Δͷ͕޷͖

Slide 3

Slide 3 text

w Ϟνϕʔγϣϯ w 5$"ʹ͍ͭͯ w ࣗ࡞ΞʔΩςΫνϟͷ֓ཁɾ࣮૷ʹ͍ͭͯ w ࣮ࡍʹΞϓϦΛ࡞੒͢Δ w ·ͱΊ

Slide 4

Slide 4 text

w Ϟνϕʔγϣϯ w 5$"ʹ͍ͭͯ w ࣗ࡞ΞʔΩςΫνϟͷ֓ཁɾ࣮૷ʹ͍ͭͯ w ࣮ࡍʹΞϓϦΛ࡞੒͢Δ w ·ͱΊ

Slide 5

Slide 5 text

Ϟνϕʔγϣϯ w 5$"ͷύϑΥʔϚϯε্ͷܽؕΛվળ͍ͨ͠ w සൟʹ4UBUFΛมߋ͢Δ7JFXΛ࡞੒͍ͨ͠৔߹ɺ3FEVDFSΛհͯ͠4UBUFΛ มߋ͢ΔͱΧΫΧΫʹͳΔ w 5$"ʹͳ͔ͬͨཉ͍͠ػೳΛऔΓೖΕ͍ͨ w ΑΓγϯϓϧʹɺΘ͔Γ΍͍ͨ͘͢͠ w ͳΜͱͳࣗ͘࡞ͰΞʔΩςΫνϟΛ࡞ͬͯΔਓʹಌΕ͕͋ͬͨ

Slide 6

Slide 6 text

w Ϟνϕʔγϣϯ w 5$"ʹ͍ͭͯ w ࣗ࡞ΞʔΩςΫνϟͷ֓ཁɾ࣮૷ʹ͍ͭͯ w ࣮ࡍʹΞϓϦΛ࡞੒͢Δ w ·ͱΊ

Slide 7

Slide 7 text

5$"ʹ͍ͭͯ

Slide 8

Slide 8 text

5$"ʹ͍ͭͯ w ΞϓϦέʔγϣϯͷঢ়ଶΛҰݩԽͯ͠4JOHMF4PVSDFPG5SVUIͷ࣮ݱΛͯ͠ ͍Δ w ਌ͷ4UBUF͔Βࢠͷ4UBUFʹΞΫηεՄೳ 4UBUF

Slide 9

Slide 9 text

5$"ʹ͍ͭͯ 3FEVDFS w ਌ͷ3FEVDFS͸͢΂ͯͷࢠͷ3FEVDFSͷ"DUJPOΛड͚औΕΔ

Slide 10

Slide 10 text

5$"ʹ͍ͭͯ ύϑΥʔϚϯε ͦͷੑ্࣭ ֊૚͕Ұ൪Լͷ3FEVDFSͰ"DUJPO͕ݺ͹Εͨͱͯ͠΋ɺ਌͔Βࢠ·Ͱͷ ϧʔτʹ͋Δ͢΂ͯͷ3FEVDFS͕Α͹Εͯ͠·͏ɻ Αͬͯɺ֊૚͕ԼʹͳΔʹͭΕͯ"DUJPOΛૹ৴͢Δܭࢉίετ͕͔͔Δ "DUJPO͕ൃՐʂ

Slide 11

Slide 11 text

5$"ʹ͍ͭͯ ϕϯνϚʔΫ ωετ͕ਂ͘ͳΕ͹ͳΔ΄Ͳɺ"DUJPOͷૹ৴଎౓͕஗͘ͳ͍ͬͯΔ͜ͱ͕Θ͔Δ

Slide 12

Slide 12 text

w Ϟνϕʔγϣϯ w 5$"ʹ͍ͭͯ w ࣗ࡞ΞʔΩςΫνϟͷ֓ཁɾ࣮૷ʹ͍ͭͯ w ࣮ࡍʹΞϓϦΛ࡞੒͢Δ w ·ͱΊ

Slide 13

Slide 13 text

ࣗ࡞ΞʔΩςΫνϟͷ֓ཁ w 3FEVDFSͷΠϯλʔϑΣʔε͸΄΅5$" w 5$"ͱҧͬͯࢠ͸਌3FEVDFSʹ݁߹͠ͳ͍ w 7JFXͷঢ়ଶ؅ཧʹ!4UBUF΍!#JOEJOH !1VCMJTIFEͳͲ͕࢖͑Δ w ςελϒϧ w 3FEVDFS಺ͷΈʹ࢖༻Ͱ͖Δ3FEVDFS4UBUF΍3FEVDFS"DUJPOͳͲ͕͋Δ

Slide 14

Slide 14 text

7JFX @ViewState struct CounterView: View { @State var counter = 0 let store: Store = Store(reducer: CounterReducer()) var body: some View { VStack { Text("\(counter)") Button("+") { send(.increment) } Button("-") { send(.decrement) } } } } ࣗ࡞ΞʔΩςΫνϟͷ֓ཁ !4UBUFΛ࢖༻ͯ͠ঢ়ଶΛఆٛ

Slide 15

Slide 15 text

3FEVDFS ࣗ࡞ΞʔΩςΫνϟͷ֓ཁ @Reducer struct CounterReducer { enum ViewAction { case increment case decrement } func reduce( into state: StateContainer, action: Action ) -> SideEffect { switch action { case .increment: state.counter += 1 return .none case .decrement: state.counter -= 1 return .none } } } @ViewState struct CounterView: View { @State var counter = 0 ... } ઌ΄Ͳ7JFXͰఆٛͨ͠ঢ়ଶ͕3FEVDFS಺ͰมߋͰ͖Δ

Slide 16

Slide 16 text

3FEVDFS4UBUF ࣗ࡞ΞʔΩςΫνϟͷ֓ཁ @Reducer struct CounterReducer { ... struct ReducerState { var totalCalledCount = 0 } func reduce( into state: StateContainer, action: Action ) -> SideEffect { state.reducerState.totalCalledCount += 1 ... } }

Slide 17

Slide 17 text

3FEVDFS4UBUF ࣗ࡞ΞʔΩςΫνϟͷ֓ཁ w 5$"Ͱ͸7JFX4UBUFͱݺ͹ΕΔ֓೦͕͋Γɺෆඞཁͳ7JFXͷ࠶ϨϯμϦϯάΛ཈͑ͨ Γɺ7JFX༻ʹTUBUFΛՃ޻ͨ͠Γ͢Δ໨తͰ࢖༻͞ΕΔɻ w 3FEVDFS4UBUF΋ಉ͡Α͏ʹ ঢ়ଶΛมߋͯ͠΋7JFXͷ࠶ϨϯμϦϯά͸ى͜Βͳ͍ w 7JFXͷΈͰ࢖༻͢Δ4UBUFͱɺ3FEVDFSͷΈͰ࢖༻͢Δ4UBUFͱͰ෼͚Δ͜ͱ͕Ͱ͖ɺ 7JFXଆͷՄಡੑͷ޲্ʹͭͳ͕Δ

Slide 18

Slide 18 text

3FEVDFS"DUJPO ࣗ࡞ΞʔΩςΫνϟͷ֓ཁ @Reducer struct CounterReducer { enum ViewAction { case onAppear } enum ReducerAction { case response(TaskResult) } func reduce( into state: StateContainer, action: Action ) -> SideEffect { switch action { case .onAppear: return .run { send in await send( .response( TaskResult { try await request() } ) ) } case .response(let result): // ... do something } } } w 5$"Ͱ͸3FEVDFS಺ͷΈΞΫηεͰ͖Δ QSJWBUFͳ"DUJPO͸࡞Εͳ͔͕ͬͨɺຊΞʔ ΩςΫνϟͰ͸αϙʔτ w ྫ͑͹ɺ"1*͔Βͷ3FTQPOTFͳͲɺ7JFX͔ Β͸ݺͼͨ͘ͳ͍"DUJPOΛఆٛͰ͖Δ

Slide 19

Slide 19 text

3FEVDFS"DUJPO ࣗ࡞ΞʔΩςΫνϟͷ֓ཁ @Reducer struct CounterReducer { enum ViewAction { case onAppear } enum ReducerAction { case response(TaskResult) } func reduce( into state: StateContainer, action: Action ) -> SideEffect { switch action { case .onAppear: return .run { send in await send( .response( TaskResult { try await request() } ) ) } case .response(let result): // ... do something } } } 3FEVDFS಺ʹ7JFX"DUJPOͱ3FEVDFS"DUJPO͠ ͔ఆ͍ٛͯ͠ͳ͍ͷʹɺ ͳ͔ͥ"DUJPO͕ར༻Ͱ͖Δɻ ͜ͷ"DUJPO͸ͳʹ͔ʁ

Slide 20

Slide 20 text

3FEVDFS"DUJPO ࣗ࡞ΞʔΩςΫνϟͷ֓ཁ @Reducer struct CounterReducer { enum ViewAction { case onAppear } enum ReducerAction { case response(TaskResult) } func reduce( into state: StateContainer, action: Action ) -> SideEffect { switch action { case .onAppear: return .run { send in await send( .response( TaskResult { try await request() } ) ) } case .response(let result): // ... do something } } } 3FEVDFSϚΫϩʹΑͬͯɺ7JFX"DUJPOͱ 3FEVDFS"DUJPOΛ߹ମͨ͠"DUJPO͕ੜ੒͞ΕΔ enum Action: ActionProtocol { case onAppear case response(TaskResult) init(viewAction: ViewAction) { switch viewAction { case .onAppear: self = .onAppear } } init(reducerAction: ReducerAction) { switch reducerAction { case .response(let arg1): self = .response(arg1) } } }

Slide 21

Slide 21 text

0CTFSWBCMF0CKFDU ࣗ࡞ΞʔΩςΫνϟͷ֓ཁ @ViewState final class MyModel: ObservableObject { @Published var count: Int = 0 let store: Store = .init(reducer: MyReducer()) } w 0CTFSWBCMF0CKFDUͷ࢖༻΋ՄೳͰɺϩδοΫɾঢ়ଶΛڞ༗͍ͨ͠৔߹ ΍ɺ&OWJSPONFOU0CKFDUΛ࢖༻͍ͨ͠৔߹ͳͲʹ༗ޮ struct MyView: View { @ObservedObject private var model = MyModel() var body: some View { Button("+") { model.send(.increment) } } }

Slide 22

Slide 22 text

ςετ ࣗ࡞ΞʔΩςΫνϟͷ֓ཁ 5$"ͱ΄΅ಉ͡Α͏ʹςετ͕ॻ͚Δ func testSearchButtonTapped() async { let store = SearchView().testStore( viewState: SearchView.ViewState(), withDependencies: { $0.itemClient = ItemClient { _ in .stub } } ) await store.send(.onSearchButtonTapped) { $0.isLoading = true } await store.receive(.fetchItemsResponse(.success(.stub))) { $0.items = .stub $0.isLoading = false } }

Slide 23

Slide 23 text

ςετ ࣗ࡞ΞʔΩςΫνϟͷ֓ཁ ͔͠͠ɺ!4UBUF΍!#JOEJOH͸6OJU5FTUΛߦͳ͍ͬͯΔࡍɺ஋ΛೖΕͯ΋൓ө͞Εͳ͍ ͲͷΑ͏ʹςελϒϧʹ͍ͯ͠Δͷ͔ʁ

Slide 24

Slide 24 text

ςετ ࣗ࡞ΞʔΩςΫνϟͷ࣮૷ ݁࿦ɺςετͷ࣌ͷΈ7JFXͷϓϩύςΟΛมߋ͢ΔͷͰ͸ͳ͘ɺ !7JFX4UBUFϚΫϩʹΑͬͯੜ੒͞Εͨߏ଄ମͷϓϩύςΟΛมߋ͍ͯ͠Δ func testSearchButtonTapped() async { let store = SearchView().testStore( viewState: SearchView.ViewState(), withDependencies: { $0.itemClient = ItemClient { _ in .stub } } ) await store.send(.onSearchButtonTapped) { $0.isLoading = true } await store.receive(.fetchItemsResponse(.success(.stub))) { $0.items = .stub $0.isLoading = false } }

Slide 25

Slide 25 text

ࣗ࡞ΞʔΩςΫνϟͷ࣮૷ @ViewState struct MyView: View { @State var count = 0 @State var isLoading = false var body: some View { EmptyView() } } 7JFX4UBUFϚΫϩΛల։͢Δͱ

Slide 26

Slide 26 text

ࣗ࡞ΞʔΩςΫνϟͷ࣮૷ @ViewState struct MyView: View { @State var count = 0 @State var isLoading = false var body: some View { EmptyView() } internal struct ViewState: ViewStateProtocol { var count = 0 var isLoading = false internal static let keyPathMap: [PartialKeyPath: PartialKeyPath] = [ \.count: \.count, \.isLoading: \.isLoading ] } } extension MyView: ActionSendable { } 7JFX4UBUFߏ଄ମ͕ੜ੒͞Ε "DUJPO4FOEBCMFʹ४ڌ͞ΕΔ

Slide 27

Slide 27 text

ࣗ࡞ΞʔΩςΫνϟͷ࣮૷ @ViewState struct MyView: View { @State var count = 0 @State var isLoading = false var body: some View { EmptyView() } internal struct ViewState: ViewStateProtocol { var count = 0 var isLoading = false internal static let keyPathMap: [PartialKeyPath: PartialKeyPath] = [ \.count: \.count, \.isLoading: \.isLoading ] } } extension MyView: ActionSendable { } !4UBUF͕෇༩͞ΕͨϓϩύςΟͷΈ7JFX4UBUFʹϓϩύςΟ͕ίϐʔ͞ΕΔ

Slide 28

Slide 28 text

ࣗ࡞ΞʔΩςΫνϟͷ࣮૷ 7JFX4UBUFͷ,FZ1BUI͔Β.Z7JFXͷ,FZ1BUI΁ͱม׵Ͱ͖ΔLFZ1BUI.BQΛఆٛ ޙ΄Ͳ࢖༻ @ViewState struct MyView: View { @State var count = 0 @State var isLoading = false var body: some View { EmptyView() } internal struct ViewState: ViewStateProtocol { var count = 0 var isLoading = false internal static let keyPathMap: [PartialKeyPath: PartialKeyPath] = [ \.count: \.count, \.isLoading: \.isLoading ] } } extension MyView: ActionSendable { }

Slide 29

Slide 29 text

ࣗ࡞ΞʔΩςΫνϟͷ࣮૷ 7JFX4UBUFߏ଄ମʹ͸ͭͷॏཁͳ໾ׂ͕͋Δ CPEZ΍TUPSFͳͲɺ7JFXͷঢ়ଶʹؔ܎ͷͳ͍ϓϩύςΟΛ4UBUF$POUBJOFSͰӅṭ͢Δ ςελϒϧʹ͢Δ func reduce( into state: StateContainer, action: Action ) -> SideEffect { switch action { case .increment: state.counter += 1 return .none } } TUBUF͔ΒɺCPEZ΍TUPSFʹ͸ΞΫηεͰ͖ͳ͍

Slide 30

Slide 30 text

ࣗ࡞ΞʔΩςΫνϟͷ࣮૷ public subscript(dynamicMember keyPath: WritableKeyPath) -> U { _read { #if DEBUG guard !isTesting else { yield viewState![keyPath: keyPath] return } #endif if let viewKeyPath = Target.ViewState.keyPathMap[keyPath] as? WritableKeyPath { yield target[keyPath: viewKeyPath] } else { fatalError() } } _modify { #if DEBUG guard !isTesting else { yield &viewState![keyPath: keyPath] return } #endif if let viewKeyPath = Target.ViewState.keyPathMap[keyPath] as? WritableKeyPath { yield &target[keyPath: viewKeyPath] } else { fatalError() } } } EZOBNJD.FNCFS-PPLVQͱ͸ɺΦϒδΣΫτʹଘࡏ͠ͳ͍ϓϩύςΟΛ ͔͋ͨ΋ଘࡏ͢ΔΑ͏ʹݟͤΔ͜ͱ͕Ͱ͖Δػೳ 4UBUF$POUBJOFS

Slide 31

Slide 31 text

ࣗ࡞ΞʔΩςΫνϟͷ࣮૷ public subscript(dynamicMember keyPath: WritableKeyPath) -> U { _read { #if DEBUG guard !isTesting else { yield viewState![keyPath: keyPath] return } #endif if let viewKeyPath = Target.ViewState.keyPathMap[keyPath] as? WritableKeyPath { yield target[keyPath: viewKeyPath] } else { fatalError() } } _modify { #if DEBUG guard !isTesting else { yield &viewState![keyPath: keyPath] return } #endif if let viewKeyPath = Target.ViewState.keyPathMap[keyPath] as? WritableKeyPath { yield &target[keyPath: viewKeyPath] } else { fatalError() } } } ද޲͖͸7JFX4UBUFͷϓϩύςΟΛ͍࣋ͬͯΔ͔ͷΑ͏ʹݟͤΔ͜ͱͰɺ 3FEVDFS͔ΒCPEZ΍TUPSFͳͲͷϓϩύςΟʹΞΫηεͰ͖ͳ͍Α͏ʹ͍ͯ͠Δ 4UBUF$POUBJOFS

Slide 32

Slide 32 text

ࣗ࡞ΞʔΩςΫνϟͷ࣮૷ public subscript(dynamicMember keyPath: WritableKeyPath) -> U { _read { #if DEBUG guard !isTesting else { yield viewState![keyPath: keyPath] return } #endif if let viewKeyPath = Target.ViewState.keyPathMap[keyPath] as? WritableKeyPath { yield target[keyPath: viewKeyPath] } else { fatalError() } } _modify { #if DEBUG guard !isTesting else { yield &viewState![keyPath: keyPath] return } #endif if let viewKeyPath = Target.ViewState.keyPathMap[keyPath] as? WritableKeyPath { yield &target[keyPath: viewKeyPath] } else { fatalError() } } } ཪͰ͸ɺҾ਺Ͱड͚औͬͨ,FZ1BUIΛLFZ1BUI.BQΛ࢖༻ͯ͠7JFX4UBUF͔Β7JFXͷ ,FZ1BUI΁ͱม׵ͯ͠ 4UBUF$POUBJOFS

Slide 33

Slide 33 text

ࣗ࡞ΞʔΩςΫνϟͷ࣮૷ public subscript(dynamicMember keyPath: WritableKeyPath) -> U { _read { #if DEBUG guard !isTesting else { yield viewState![keyPath: keyPath] return } #endif if let viewKeyPath = Target.ViewState.keyPathMap[keyPath] as? WritableKeyPath { yield target[keyPath: viewKeyPath] } else { fatalError() } } _modify { #if DEBUG guard !isTesting else { yield &viewState![keyPath: keyPath] return } #endif if let viewKeyPath = Target.ViewState.keyPathMap[keyPath] as? WritableKeyPath { yield &target[keyPath: viewKeyPath] } else { fatalError() } } } 7JFXͷ஋ΛಡΈࠐΜͩΓॻ͖ࠐΜͩΓ͍ͯ͠Δɻ 4UBUF$POUBJOFS

Slide 34

Slide 34 text

ࣗ࡞ΞʔΩςΫνϟͷ࣮૷ public subscript(dynamicMember keyPath: WritableKeyPath) -> U { _read { #if DEBUG guard !isTesting else { yield viewState![keyPath: keyPath] return } #endif if let viewKeyPath = Target.ViewState.keyPathMap[keyPath] as? WritableKeyPath { yield target[keyPath: viewKeyPath] } else { fatalError() } } _modify { #if DEBUG guard !isTesting else { yield &viewState![keyPath: keyPath] return } #endif if let viewKeyPath = Target.ViewState.keyPathMap[keyPath] as? WritableKeyPath { yield &target[keyPath: viewKeyPath] } else { fatalError() } } } 4UBUF$POUBJOFS ςετ࣌͸7JFX΁ͷॻ͖ࠐΈͰ͸ͳ͘ɺ7JFX4UBUF΁ͷಡΈɾॻ͖ࠐΈʹ͢Δ͜ͱʹΑͬͯɺ ςελϒϧʹ͍ͯ͠Δ

Slide 35

Slide 35 text

w Ϟνϕʔγϣϯ w 5$"ʹ͍ͭͯ w ࣗ࡞ΞʔΩςΫνϟͷ֓ཁɾ࣮૷ʹ͍ͭͯ w ࣮ࡍʹΞϓϦΛ࡞੒͢Δ w ·ͱΊ

Slide 36

Slide 36 text

࣮ࡍʹΞϓϦΛ࡞੒͢Δ ࣮ࡍʹ؆қతͳ(JUIVCͷϦϙδτϦݕࡧΞϓϦΛ࡞Δ

Slide 37

Slide 37 text

࣮ࡍʹΞϓϦΛ࡞੒͢Δ @ViewState struct RootView: View { @State var searchText = "" @State var isLoading = false @State var repositories: [Repository] = [] @State var alertState: AlertState? let store: Store = Store(reducer: RootReducer()) var body: some View { NavigationStack { List { ForEach(repositories) { repository in NavigationLink(repository.fullName) { RepositoryView(repository: repository) .navigationTitle(repository.fullName) } } } .overlay { if isLoading { ProgressView() } } .searchable(text: $searchText) .onSubmit(of: .search) { send(.onSearchButtonTapped) } .onChange(of: searchText) { _, newValue in send(.onTextChanged(newValue)) } .alert(target: self, unwrapping: $alertState) } } 7JFX

Slide 38

Slide 38 text

࣮ࡍʹΞϓϦΛ࡞੒͢Δ 3FEVDFS "DUJPO @Reducer struct RootReducer { enum ViewAction: Equatable { case onSearchButtonTapped case onTextChanged(String) } enum ReducerAction: Equatable { case fetchRepositoriesResponse(TaskResult<[Repository]>) case alert(Alert) enum Alert: Equatable { case retry } } ... }

Slide 39

Slide 39 text

࣮ࡍʹΞϓϦΛ࡞੒͢Δ 3FEVDFS func reduce(into state: StateContainer, action: Action) -> SideEffect { switch action { case .onSearchButtonTapped: state.isLoading = true return fetchRepositories(query: state.searchText) case .onTextChanged(let text): if text.isEmpty { state.repositories = [] } return .none case let .fetchRepositoriesResponse(.success(repositories)): state.isLoading = false state.repositories = repositories return .none case let .fetchRepositoriesResponse(.failure(error)): state.isLoading = false state.alertState = .init { TextState("An Error has occurred.") } actions: { ButtonState { TextState("OK") } ButtonState(action: .alert(.retry)) { TextState("Retry") } } message: { TextState(error.localizedDescription) } return .none case .alert(.retry): state.isLoading = true return fetchRepositories(query: state.searchText) }

Slide 40

Slide 40 text

࣮ࡍʹΞϓϦΛ࡞੒͢Δ 3FEVDFS func reduce(into state: StateContainer, action: Action) -> SideEffect { switch action { case .onSearchButtonTapped: state.isLoading = true return fetchRepositories(query: state.searchText) case .onTextChanged(let text): if text.isEmpty { state.repositories = [] } return .none case let .fetchRepositoriesResponse(.success(repositories)): state.isLoading = false state.repositories = repositories return .none case let .fetchRepositoriesResponse(.failure(error)): state.isLoading = false state.alertState = .init { TextState("An Error has occurred.") } actions: { ButtonState { TextState("OK") } ButtonState(action: .alert(.retry)) { TextState("Retry") } } message: { TextState(error.localizedDescription) } return .none case .alert(.retry): state.isLoading = true return fetchRepositories(query: state.searchText) } .onSubmit(of: .search) { send(.onSearchButtonTapped) } ݕࡧϘλϯ͕ԡ͞Εͨ࣌ɺϩʔυΛ։࢝͠ɺ ϦϙδτϦΛऔಘ͍ͯ͠Δ

Slide 41

Slide 41 text

࣮ࡍʹΞϓϦΛ࡞੒͢Δ 3FEVDFS func reduce(into state: StateContainer, action: Action) -> SideEffect { switch action { case .onSearchButtonTapped: state.isLoading = true return fetchRepositories(query: state.searchText) case .onTextChanged(let text): if text.isEmpty { state.repositories = [] } return .none case let .fetchRepositoriesResponse(.success(repositories)): state.isLoading = false state.repositories = repositories return .none case let .fetchRepositoriesResponse(.failure(error)): state.isLoading = false state.alertState = .init { TextState("An Error has occurred.") } actions: { ButtonState { TextState("OK") } ButtonState(action: .alert(.retry)) { TextState("Retry") } } message: { TextState(error.localizedDescription) } return .none case .alert(.retry): state.isLoading = true return fetchRepositories(query: state.searchText) } func fetchRepositories( query: String ) -> SideEffect { .run { send in await send( .fetchRepositoriesResponse( TaskResult { try await fetchRepositories(query) } ) ) } } ϦΫΤετΛૹ৴͠ɺ݁ՌΛ3FEVDFS"DUJPOͷ GFUDI3FQPTJUPSJFT3FTQPOTFΛૹ৴͍ͯ͠Δ

Slide 42

Slide 42 text

࣮ࡍʹΞϓϦΛ࡞੒͢Δ 3FEVDFS func reduce(into state: StateContainer, action: Action) -> SideEffect { switch action { case .onSearchButtonTapped: state.isLoading = true return fetchRepositories(query: state.searchText) case .onTextChanged(let text): if text.isEmpty { state.repositories = [] } return .none case let .fetchRepositoriesResponse(.success(repositories)): state.isLoading = false state.repositories = repositories return .none case let .fetchRepositoriesResponse(.failure(error)): state.isLoading = false state.alertState = .init { TextState("An Error has occurred.") } actions: { ButtonState { TextState("OK") } ButtonState(action: .alert(.retry)) { TextState("Retry") } } message: { TextState(error.localizedDescription) } return .none case .alert(.retry): state.isLoading = true return fetchRepositories(query: state.searchText) } ੒ޭͷ৔߹͸ϩʔσΟϯάΛதࢭ͠ɺ ݁ՌΛSFQPTJUPSJFTʹ୅ೖ͍ͯ͠Δ

Slide 43

Slide 43 text

࣮ࡍʹΞϓϦΛ࡞੒͢Δ 3FEVDFS func reduce(into state: StateContainer, action: Action) -> SideEffect { switch action { case .onSearchButtonTapped: state.isLoading = true return fetchRepositories(query: state.searchText) case .onTextChanged(let text): if text.isEmpty { state.repositories = [] } return .none case let .fetchRepositoriesResponse(.success(repositories)): state.isLoading = false state.repositories = repositories return .none case let .fetchRepositoriesResponse(.failure(error)): state.isLoading = false state.alertState = .init { TextState("An Error has occurred.") } actions: { ButtonState { TextState("OK") } ButtonState(action: .alert(.retry)) { TextState("Retry") } } message: { TextState(error.localizedDescription) } return .none case .alert(.retry): state.isLoading = true return fetchRepositories(query: state.searchText) } ࣦഊͷ৔߹΋ϩʔσΟϯάΛதࢭ͠ɺΞϥʔτΛग़ ͠ɺ3FUSZϘλϯΛ͓͢ͱɺ3FEVDFS"DUJPOͷ BMFSU SFUSZ ͕ݺ͹ΕΔ

Slide 44

Slide 44 text

࣮ࡍʹΞϓϦΛ࡞੒͢Δ 3FEVDFS func reduce(into state: StateContainer, action: Action) -> SideEffect { switch action { case .onSearchButtonTapped: state.isLoading = true return fetchRepositories(query: state.searchText) case .onTextChanged(let text): if text.isEmpty { state.repositories = [] } return .none case let .fetchRepositoriesResponse(.success(repositories)): state.isLoading = false state.repositories = repositories return .none case let .fetchRepositoriesResponse(.failure(error)): state.isLoading = false state.alertState = .init { TextState("An Error has occurred.") } actions: { ButtonState { TextState("OK") } ButtonState(action: .alert(.retry)) { TextState("Retry") } } message: { TextState(error.localizedDescription) } return .none case .alert(.retry): state.isLoading = true return fetchRepositories(query: state.searchText) } ϦτϥΠ͕ԡ͞ΕΔͱɺ࠶౓ϩʔσΟϯάΛ։࢝ ͠ɺϦϙδτϦͷऔಘͷϦΫΤετΛૹ৴͢Δ

Slide 45

Slide 45 text

࣮ࡍʹΞϓϦΛ࡞੒͢Δ 3FEVDFS func reduce(into state: StateContainer, action: Action) -> SideEffect { switch action { case .onSearchButtonTapped: state.isLoading = true return fetchRepositories(query: state.searchText) case .onTextChanged(let text): if text.isEmpty { state.repositories = [] } return .none case let .fetchRepositoriesResponse(.success(repositories)): state.isLoading = false state.repositories = repositories return .none case let .fetchRepositoriesResponse(.failure(error)): state.isLoading = false state.alertState = .init { TextState("An Error has occurred.") } actions: { ButtonState { TextState("OK") } ButtonState(action: .alert(.retry)) { TextState("Retry") } } message: { TextState(error.localizedDescription) } return .none case .alert(.retry): state.isLoading = true return fetchRepositories(query: state.searchText) } ςΩετͷೖྗΛPO$IBOHF PGJOJUJBM@ Ͱݕ஌͠ɺ 7JFX"DUJPOPO5FYU$IBOHFEΛૹ৴͍ͯ͠Δ .onChange(of: searchText) { _, newValue in send(.onTextChanged(newValue)) } UFYU͕ۭʹͳͬͨ࣌ʹɺTUBUFSFQPTJUPSJFTΛۭʹ͍ͯ͠Δ

Slide 46

Slide 46 text

࣮ࡍʹΞϓϦΛ࡞੒͢Δ 5FTU @MainActor final class RootReducerTests: XCTestCase { func testSearchButtonTapped() async { let store = RootView().testStore(viewState: RootView.ViewState()) { $0.repositoryClient = RepositoryClient( fetchRepositories: { _ in [.stub] } ) } await store.send(.onSearchButtonTapped) { $0.isLoading = true } await store.receive(.fetchRepositoriesResponse(.success([.stub]))) { $0.repositories = [.stub] $0.isLoading = false } } ... } ຊΞʔΩςΫνϟ͸TXJGUEFQFOEFODJFTʹ ରԠ͍ͯ͠ΔͷͰɺ ϞοΫ΁ͷࠩ͠ସ͕͑Մೳ

Slide 47

Slide 47 text

࣮ࡍʹΞϓϦΛ࡞੒͢Δ 5FTU @MainActor final class RootReducerTests: XCTestCase { func testSearchButtonTapped() async { let store = RootView().testStore(viewState: RootView.ViewState()) { $0.repositoryClient = RepositoryClient( fetchRepositories: { _ in [.stub] } ) } await store.send(.onSearchButtonTapped) { $0.isLoading = true } await store.receive(.fetchRepositoriesResponse(.success([.stub]))) { $0.repositories = [.stub] $0.isLoading = false } } ... } Ϙλϯ͕ԡ͞Εͨ࣌ɺϦΫΤετ͕੒ޭͨ͠ ࣌ͷ஋ͷঢ়ଶͷมߋΛςετ

Slide 48

Slide 48 text

࣮ࡍʹΞϓϦΛ࡞੒͢Δ 5FTU func testSearchButtonTappedInFailure() async { let error = CancellationError() let store = RootView().testStore(viewState: RootView.ViewState()) { $0.repositoryClient = RepositoryClient( fetchRepositories: { _ in throw error } ) } await store.send(.onSearchButtonTapped) { $0.isLoading = true } await store.receive(.fetchRepositoriesResponse(.failure(error))) { $0.isLoading = false $0.alertState = .init { TextState("An Error has occurred.") } actions: { ButtonState { TextState("OK") } ButtonState(action: .alert(.retry)) { TextState("Retry") } } message: { TextState(error.localizedDescription) } } } ࣍ʹϦΫΤετதʹΤϥʔΛൃੜͤͨ͞৔ ߹ͷςετΛ͢Δ

Slide 49

Slide 49 text

࣮ࡍʹΞϓϦΛ࡞੒͢Δ 5FTU func testSearchButtonTappedInFailure() async { let error = CancellationError() let store = RootView().testStore(viewState: RootView.ViewState()) { $0.repositoryClient = RepositoryClient( fetchRepositories: { _ in throw error } ) } await store.send(.onSearchButtonTapped) { $0.isLoading = true } await store.receive(.fetchRepositoriesResponse(.failure(error))) { $0.isLoading = false $0.alertState = .init { TextState("An Error has occurred.") } actions: { ButtonState { TextState("OK") } ButtonState(action: .alert(.retry)) { TextState("Retry") } } message: { TextState(error.localizedDescription) } } } Τϥʔ͕ฦ͖ͬͯͨ৔߹ͷঢ়ଶͷมߋΛςετ

Slide 50

Slide 50 text

w Ϟνϕʔγϣϯ w 5$"ʹ͍ͭͯ w ࣗ࡞ΞʔΩςΫνϟͷ֓ཁɾ࣮૷ʹ͍ͭͯ w ࣮ࡍʹΞϓϦΛ࡞੒͢Δ w ·ͱΊ

Slide 51

Slide 51 text

·ͱΊ ϕϯνϚʔΫ ௚઀!1VCMJTIFEʹॻ͖ࠐΉͷʹൺ΂͓ͯΑͦഒͷ଎౓ʹ཈͑ΒΕͨ 5$"ͰҰճ"DUJPO͕ωετͨ͠ঢ়ଶΑΓɺഒߴ଎

Slide 52

Slide 52 text

·ͱΊ 5$"ΑΓྑ͍఺ɾѱ͍఺ w ࢠΛ਌υϝΠϯʹ݁߹͠ͳ͍ͷͰɺͲΕ͚ͩ7JFXͷ֊૚͕ԼͰ΋ύϑΥʔϚϯε͕มΘΒͳ͍ w ԿΑΓ΋γϯϓϧʹΞϓϦ͕࡞ΕΔ w 5$"ʹ͸͞·͟·ͳϝιου΍NPEJ fi FS ΧελϜ7JFX͕ଘࡏ͢Δ͕ɺͦΕΒͷ࢖͍ํΛ֮͑Δඞཁ͕ͳ͍ ྑ͍఺ ѱ͍఺ w ࢠΛ਌υϝΠϯʹ݁߹͠ͳ͍͜ͱʹΑΓɺ౷߹ςετ͕ॻ͚ͳ͘ͳΔ w Ұݩతͳঢ়ଶ؅ཧΛ͍ͯ͠ͳ͍ͨΊɺঢ়ଶͷෆ੔߹͕5$"ͷํ͕ൃੜ͠ʹ͍͘

Slide 53

Slide 53 text

·ͱΊ ΞʔΩςΫνϟΛࣗ࡞ͯ͠ྑ͔ͬͨ఺ wύϑΥʔϚϯεΛ͍͔ʹͯ͋͛͠Δ͔Λߟ͑ͳ͕ΒϥΠϒϥϦΛ࡞੒͍ͯͨ͠ͷͰɺ ͲͷΑ͏ͳίʔυ͕ޮ཰͕ྑ͍͔͕Θ͔ͬͨ w5$"ͷ಺෦ͰͲͷΑ͏ͳॲཧ͕ߦΘΕ͍ͯΔͷ͔͕͍͍ͩͨཧղͰ͖ͯྑ͔ͬͨ

Slide 54

Slide 54 text

͍͞͝ʹ IUUQTHJUIVCDPN3ZVTXJGUVJTJNQMFYBSDIJUFDUVSF ৄ͘͠ίʔυͳͲΛΈ͍ͨ৔߹͸ɺͥͻͪ͜Β͔Β͓ئ͍͠·͢ʂ ͝ਗ਼ௌ͋Γ͕ͱ͏͍͟͝·͢ʂ