/SwiftUI-Hooks

A SwiftUI implementation of React Hooks. Enhances reusability of stateful logic and gives state and lifecycle to function view.

Primary LanguageSwiftMIT LicenseMIT

SwiftUI Hooks

A SwiftUI implementation of React Hooks.

Enhances reusability of stateful logic and gives state and lifecycle to function view.

Swift5 Platform GitHub Actions



Introducing Hooks

struct Example: HookView {
    var hookBody: some View {
        let time = useState(Date())

        useEffect(.once) {
            let timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) {
                time.wrappedValue = $0.fireDate
            }

            return {
                timer.invalidate()
            }
        }

        return Text("Now: \(time.wrappedValue)")
    }
}

SwiftUI Hooks is a SwiftUI implementation of React Hooks. Brings the state and lifecycle into the function view, without depending on elements that are only allowed to be used in struct views such as @State or @ObservedObject.
It allows you to reuse stateful logic between views by building custom hooks composed with multiple hooks.
Furthermore, hooks such as useEffect also solve the problem of lack of lifecycles in SwiftUI.

The API and behavioral specs of SwiftUI Hooks are entirely based on React Hooks, so you can leverage your knowledge of web applications to your advantage.

There're already a bunch of documentations on React Hooks, so you can refer to it and learn more about Hooks.

Flutter Hooks encouraged me to create SwiftUI Hooks, and its well implemented design gives me a lot of inspiration.
Kudos to @rrousselGit and of course React Team.


Hooks API

👇 Click to open the description.

useState
func useState<State>(_ initialState: State) -> Binding<State>

A hook to use a Binding<State> wrapping current state to be updated by setting a new state to wrappedValue.
Triggers a view update when the state has been changed.

let count = useState(0)  // Binding<Int>
count.wrappedValue = 123
useEffect
func useEffect(_ computation: HookComputation, _ effect: @escaping () -> (() -> Void)?)

A hook to use a side effect function that is called the number of times according to the strategy specified by computation.
Optionally the function can be cancelled when this hook is unmount from the view tree or when the side-effect function is called again.
Note that the execution is deferred until after all the hooks have been evaluated.

useEffect(.once) {
    print("View is mounted")

    return {
        print("View is unmounted")
    }
}
useLayoutEffect
func useLayoutEffect(_ computation: HookComputation, _ effect: @escaping () -> (() -> Void)?)

A hook to use a side effect function that is called the number of times according to the strategy specified by computation.
Optionally the function can be cancelled when this hook is unmount from the view tree or when the side-effect function is called again.
The signature is identical to useEffect, but this fires synchronously when the hook is called.

useLayoutEffect(.always) {
    print("View is being evaluated")
    return nil
}
useMemo
func useMemo<Value>(_ computation: HookComputation, _ makeValue: @escaping () -> Value) -> Value

A hook to use memoized value preserved until it is re-computed at the timing specified with the computation

let random = useMemo(.once) {
    Int.random(in: 0...100)
}
useRef
func useRef<T>(_ initialValue: T) -> RefObject<T>

A hook to use a mutable ref object storing an arbitrary value.
The essential of this hook is that setting a value to current doesn't trigger a view update.

let value = useRef("text")  // RefObject<String>
value.current = "new text"
useReducer
func useReducer<State, Action>(_ reducer: @escaping (State, Action) -> State, initialState: State) -> (state: State, dispatch: (Action) -> Void)

A hook to use the current state computed with the passed reducer, and a dispatch function to dispatch an action to mutate the compute a new state.
Triggers a view update when the state has been changed.

enum Action {
    case increment, decrement
}

func reducer(state: Int, action: Action) -> Int {
    switch action {
        case .increment:
            return state + 1

        case .decrement:
            return state - 1
    }
}

let (count, dispatch) = useReducer(reducer, initialState: 0)
useEnvironment
func useEnvironment<Value>(_ keyPath: KeyPath<EnvironmentValues, Value>) -> Value

A hook to use environment value passed through the view tree without @Environment property wrapper.

let colorScheme = useEnvironment(\.colorScheme)  // ColorScheme
usePublisher
func usePublisher<P: Publisher>(_ computation: HookComputation, _ makePublisher: @escaping () -> P) -> AsyncStatus<P.Output, P.Failure>

A hook to use the most recent status of asynchronous operation of the passed publisher.
The publisher will be subscribed at the first computation and will be re-subscribed according to the strategy specified with the passed computation.
Triggers a view update when the asynchronous status has been changed.

let status = usePublisher(.once) {
    URLSession.shared.dataTaskPublisher(for: url)
}
usePublisherSubscribe
func usePublisherSubscribe<P: Publisher>(_ makePublisher: @escaping () -> P) -> (status: AsyncStatus<P.Output, P.Failure>, subscribe: () -> Void)

A hook to use the most recent status of asynchronous operation of the passed publisher, and a subscribe function to be started to subscribe arbitrary timing.
Update the view with the asynchronous status change.

let (status, subscribe) = usePublisherSubscribe {
    URLSession.shared.dataTaskPublisher(for: url)
}
useContext
func useContext<T>(_ context: Context<T>.Type) -> T

A hook to use current context value that is provided by Context<T>.Provider.
The purpose is identical to use Context<T>.Consumer.
See Context section for more details.

let value = useContext(Context<Int>.self)  // Int

See also: React Hooks API Reference


Rules of Hooks

In order to take advantage of the wonderful interface of Hooks, the same rules that React hooks has must also be followed by SwiftUI Hooks.

Only Call Hooks at the Function Top Level

Do not call Hooks inside conditions or loops. The order in which hook is called is important since Hooks uses LinkedList to keep track of its state.

🟢 DO

@ViewBuilder
var counterButton: some View {
    let count = useState(0)  // Uses hook at the top level

    Button("You clicked \(count.wrappedValue) times") {
        count.wrappedValue += 1
    }
}

🔴 DON'T

@ViewBuilder
var counterButton: some View {
    if condition {
        let count = useState(0)  // Uses hook inside condition.

        Button("You clicked \(count.wrappedValue) times") {
            count.wrappedValue += 1
        }
    }
}

Only Call Hooks from HookScope or HookView.hookBody

In order to preserve the state, hooks must be called inside a HookScope.
A view that conforms to the HookView protocol will automatically be enclosed in a HookScope.

🟢 DO

struct ContentView: HookView {  // `HookView` is used.
    var hookBody: some View {
        let count = useState(0)

        Button("You clicked \(count.wrappedValue) times") {
            count.wrappedValue += 1
        }
    }
}
struct ContentView: View {
    var body: some View {
        HookScope {  // `HookScope` is used.
            let count = useState(0)

            Button("You clicked \(count.wrappedValue) times") {
                count.wrappedValue += 1
            }
        }
    }
}

🔴 DON'T

struct ContentView: View {
    var body: some View {  // Neither `HookScope` nor `HookView` is used.
        let count = useState(0)

        Button("You clicked \(count.wrappedValue) times") {
            count.wrappedValue += 1
        }
    }
}

See also: Rules of React Hooks


Building Your Own Hooks

Building your own hooks lets you extract stateful logic into reusable functions.
Hooks are composable since they serve as a stateful functions. So, they can be able to compose with other hooks to create your own custom hook.

In the following example, the most basic useState and useEffect are used to make a function provides a current Date with the specified interval. If the specified interval is changed, Timer.invalidate() will be called and then a new timer will be activated.
Like this, the stateful logic can be extracted out as a function using Hooks.

func useTimer(interval: TimeInterval) -> Date {
    let time = useState(Date())

    useEffect(.preserved(by: interval)) {
        let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) {
            time.wrappedValue = $0.fireDate
        }

        return {
            timer.invalidate()
        }
    }

    return time.wrappedValue
}

Let's refactor the Example view at the beginning of the README using this custom hook.

struct Example: HookView {
    var hookBody: some View {
        let time = useTimer(interval: 1)

        Text("Now: \(time)")
    }
}

It's so much easier to read and less codes!
Of course the stateful custom hook can be called by arbitrary views.

See also: Building Your Own React Hooks


How to Test Your Custom Hooks

So far, we have explained that hooks should be called within HookScope or HookView. Then, how can the custom hook you have created be tested?
For such purposes, there is an API to create a temporary hook scope independent of SwiftUI view.

Within the withTemporaryHookScope function, you can launch the hook scope multiple times to test state transitions in cases such as when the SwiftUI view is evaluated multiple times.

For example:

withTemporaryHookScope { scope in
    scope {
        let count = useState(0)
        count.wrappedValue = 1
    }

    scope {
        let count = useState(0)
        XCTAssertEqual(count.wrappedValue, 1)  // The previous state is preserved.
    }
}

Context

React has a way to pass data through the component tree without having to pass it down manually, it's called Context.
Similarly, SwiftUI has EnvironmentValues to achieve the same, but defining a custom environment value is a bit of a pain, so SwiftUI Hooks provides Context API that a more user-friendly.
This is a simple wrapper around the EnvironmentValues.

typealias ColorSchemeContext = Context<Binding<ColorScheme>>

struct ContentView: HookView {
    var hookBody: some View {
        let colorScheme = useState(ColorScheme.light)

        ColorSchemeContext.Provider(value: colorScheme) {
            darkModeButton
                .background(Color(.systemBackground))
                .colorScheme(colorScheme.wrappedValue)
        }
    }

    var darkModeButton: some View {
        ColorSchemeContext.Consumer { colorScheme in
            Button("Use dark mode") {
                colorScheme.wrappedValue = .dark
            }
        }
    }
}

And of course, there is a useContext hook that can be used instead of Context.Consumer to retrieve the provided value.

@ViewBuilder
var darkModeButton: some View {
    let colorScheme = useContext(ColorSchemeContext.self)

    Button("Use dark mode") {
        colorScheme.wrappedValue = .dark
    }
}

See also: React Context


Requirements

  • Swift 5.3+
  • Xcode 12.4.0+
  • iOS 13.0+
  • macOS 10.15+
  • tvOS 13.0+
  • watchOS 6.0+

Installation

Xcode menu File > Swift Packages > Add Package Dependency.

Repository: https://github.com/ra1028/SwiftUI-Hooks

Cartfile

github "ra1028/SwiftUI-Hooks"

Doesn't support CocoaPods master repository.

Podfile

pod 'Hooks' :git => 'https://github.com/ra1028/SwiftUI-Hooks.git'

License

MIT © Ryo Aoyama