Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 40 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,11 @@ func timer() -> some View {
}
```

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`.
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.
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.

Expand Down Expand Up @@ -273,6 +273,10 @@ See also: [React Hooks API Reference](https://reactjs.org/docs/hooks-reference.h

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.

**[Disclaimer]**: These rules are not technical constraints specific to SwiftUI Hooks, but are necessary based on the design of the Hooks itself. You can see [here](https://reactjs.org/docs/hooks-rules.html) to know more about the rules defined for React Hooks.

\* In -Onone builds, if a violation against this rules is detected, it asserts by an internal sanity check to help the developer notice the mistake in the use of hooks. However, hooks also has `disableHooksRulesAssertion` modifier in case you want to disable the assertions.

### 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](https://en.wikipedia.org/wiki/Linked_list) to keep track of its state.
Expand All @@ -281,8 +285,8 @@ Do not call Hooks inside conditions or loops. The order in which hook is called

```swift
@ViewBuilder
var counterButton: some View {
let count = useState(0) // Uses hook at the top level
func counterButton() -> some View {
let count = useState(0) // 🟢 Uses hook at the top level

Button("You clicked \(count.wrappedValue) times") {
count.wrappedValue += 1
Expand All @@ -294,9 +298,9 @@ var counterButton: some View {

```swift
@ViewBuilder
var counterButton: some View {
func counterButton() -> some View {
if condition {
let count = useState(0) // Uses hook inside condition.
let count = useState(0) // 🔴 Uses hook inside condition.

Button("You clicked \(count.wrappedValue) times") {
count.wrappedValue += 1
Expand All @@ -313,7 +317,7 @@ A view that conforms to the `HookView` protocol will automatically be enclosed i
🟢 **DO**

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

Expand All @@ -325,24 +329,26 @@ struct ContentView: HookView { // `HookView` is used.
```

```swift
struct ContentView: View {
var body: some View {
HookScope { // `HookScope` is used.
let count = useState(0)
func counterButton() -> some View {
HookScope { // 🟢 `HookScope` is used.
let count = useState(0)

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

🔴 **DON'T**

```swift
struct ContentView: View {
var body: some View { // Neither `HookScope` nor `HookView` is used.
struct ContentView: HookView {
var hookBody: some View {
counterButton()
}

// 🟢 Called from `HookView.hookBody` or `HookScope`.
@ViewBuilder
var counterButton: some View {
let count = useState(0)

Button("You clicked \(count.wrappedValue) times") {
Expand All @@ -352,6 +358,20 @@ struct ContentView: View {
}
```

🔴 **DON'T**

```swift
// 🔴 Neither `HookScope` nor `HookView` is used, and is not called from them.
@ViewBuilder
func counterButton() -> some View {
let count = useState(0)

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

See also: [Rules of React Hooks](https://reactjs.org/docs/hooks-rules.html)

---
Expand Down Expand Up @@ -404,7 +424,7 @@ See also: [Building Your Own React Hooks](https://reactjs.org/docs/hooks-custom.
## 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?
To making unit testing of your custom hooks easy, SwiftUI-Hooks provides a simple and complete test utility library.
To making unit testing of your custom hooks easy, SwiftUI Hooks provides a simple and complete test utility library.

`HookTester` enables unit testing independent of UI of custom hooks by simulating the behavior on the view of a given hook and managing the result values.

Expand All @@ -415,7 +435,7 @@ Example:
func useCounter() -> (count: Int, increment: () -> Void) {
let count = useState(0)

let increment = {
func increment() {
count.wrappedValue += 1
}

Expand Down
12 changes: 3 additions & 9 deletions Sources/Hooks/HookDispatcher.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Combine
import SwiftUI

/// A class that manages list of states of hooks used inside `HookDispatcher.scoped(disablesAssertion:environment:_)`.
/// A class that manages list of states of hooks used inside `HookDispatcher.scoped(environment:_)`.
public final class HookDispatcher: ObservableObject {
internal private(set) static weak var current: HookDispatcher?

Expand Down Expand Up @@ -99,13 +99,11 @@ public final class HookDispatcher: ObservableObject {

/// Executes the given `body` function that needs `HookDispatcher` instance with managing hooks state.
/// - Parameters:
/// - disablesAssertion: A Boolean value indicates whether to disable assertions of hooks rule.
/// - environment: A environment values that can be used for hooks used inside the `body`.
/// - body: A function that needs `HookDispatcher` and is executed inside.
/// - Throws: Rethrows an error if the given function throws.
/// - Returns: A result value that the given `body` function returns.
public func scoped<Result>(
disablesAssertion: Bool = false,
environment: EnvironmentValues,
_ body: () throws -> Result
) rethrows -> Result {
Expand All @@ -116,7 +114,6 @@ public final class HookDispatcher: ObservableObject {
Self.current = self

let scopedState = ScopedHookState(
disablesAssertion: disablesAssertion,
environment: environment,
currentRecord: records.first
)
Expand Down Expand Up @@ -154,17 +151,14 @@ private extension HookDispatcher {
}

private final class ScopedHookState {
let disablesAssertion: Bool
let environment: EnvironmentValues
var currentRecord: LinkedList<HookRecordProtocol>.Node?
var deferredUpdateRecords = LinkedList<HookRecordProtocol>()

init(
disablesAssertion: Bool,
environment: EnvironmentValues,
currentRecord: LinkedList<HookRecordProtocol>.Node?
) {
self.disablesAssertion = disablesAssertion
self.environment = environment
self.currentRecord = currentRecord
}
Expand All @@ -176,7 +170,7 @@ private final class ScopedHookState {
}

func assertConsumedState() {
guard !disablesAssertion else {
guard !environment.hooksRulesAssertionDisabled else {
return
}

Expand All @@ -192,7 +186,7 @@ private final class ScopedHookState {
}

func assertRecordingFailure<H: Hook>(hook: H, record: HookRecordProtocol) {
guard !disablesAssertion else {
guard !environment.hooksRulesAssertionDisabled else {
return
}

Expand Down
12 changes: 12 additions & 0 deletions Sources/Hooks/Internals/EnvironmentKeys.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import SwiftUI

internal extension EnvironmentValues {
var hooksRulesAssertionDisabled: Bool {
get { self[DisableHooksRulesAssertionKey.self] }
set { self[DisableHooksRulesAssertionKey.self] = newValue }
}
}

private struct DisableHooksRulesAssertionKey: EnvironmentKey {
static let defaultValue = false
}
19 changes: 19 additions & 0 deletions Sources/Hooks/ViewExtensions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import SwiftUI

public extension View {
/// Sets whether to disable assertions that an internal sanity
/// check of hooks rules.
///
/// If this is disabled and a violation of hooks rules is detected,
/// hooks will clear the unrecoverable state and attempt to continue
/// the program.
///
/// * In -O builds, assertions for hooks rules are disabled by default.
///
/// - Parameter isDisabled: A Boolean value that indicates whether
/// the assertinos are disabled for this view.
/// - Returns: A view that assertions disabled.
func disableHooksRulesAssertion(_ isDisabled: Bool) -> some View {
environment(\.hooksRulesAssertionDisabled, isDisabled)
}
}
30 changes: 18 additions & 12 deletions Tests/HooksTests/HookDispatcherTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,18 @@ final class HookDispatcherTests: XCTestCase {
}
}

var environment: EnvironmentValues {
var environment = EnvironmentValues()
environment.hooksRulesAssertionDisabled = true
return environment
}

func testScoped() {
let dispatcher = HookDispatcher()

XCTAssertNil(HookDispatcher.current)

dispatcher.scoped(environment: EnvironmentValues()) {
dispatcher.scoped(environment: environment) {
XCTAssertTrue(HookDispatcher.current === dispatcher)
}

Expand All @@ -75,7 +81,7 @@ final class HookDispatcherTests: XCTestCase {
let onceHook = TestHook(updateStrategy: .once)
let deferredHook = TestHook(updateStrategy: nil, shouldDeferredUpdate: true)

dispatcher.scoped(environment: EnvironmentValues()) {
dispatcher.scoped(environment: environment) {
let value1 = useHook(hookWithoutPreservation)
let value2 = useHook(onceHook)
let value3 = useHook(deferredHook)
Expand All @@ -85,7 +91,7 @@ final class HookDispatcherTests: XCTestCase {
XCTAssertEqual(value3, 0) // Update is deferred
}

dispatcher.scoped(environment: EnvironmentValues()) {
dispatcher.scoped(environment: environment) {
let value1 = useHook(hookWithoutPreservation)
let value2 = useHook(onceHook)
let value3 = useHook(deferredHook)
Expand All @@ -95,7 +101,7 @@ final class HookDispatcherTests: XCTestCase {
XCTAssertEqual(value3, 1) // Update is deferred
}

dispatcher.scoped(environment: EnvironmentValues()) {
dispatcher.scoped(environment: environment) {
let value1 = useHook(hookWithoutPreservation)
let value2 = useHook(onceHook)
let value3 = useHook(deferredHook)
Expand All @@ -114,7 +120,7 @@ final class HookDispatcherTests: XCTestCase {
let hook3 = TestHook(disposeCounter: disposeCounter)
let hook4 = TestHook(disposeCounter: disposeCounter)

dispatcher.scoped(disablesAssertion: true, environment: EnvironmentValues()) {
dispatcher.scoped(environment: environment) {
let value1 = useHook(hook1)
useHook(hook2)
let value3 = useHook(hook3)
Expand All @@ -125,7 +131,7 @@ final class HookDispatcherTests: XCTestCase {
XCTAssertEqual(value4, 1)
}

dispatcher.scoped(disablesAssertion: true, environment: EnvironmentValues()) {
dispatcher.scoped(environment: environment) {
let value1 = useHook(hook1)
let value3 = useHook(hook3)
let value4 = useHook(hook4)
Expand All @@ -142,7 +148,7 @@ final class HookDispatcherTests: XCTestCase {
XCTAssertEqual(hook4.disposedAt, 1)
XCTAssertEqual(disposeCounter.count, 3)

dispatcher.scoped(disablesAssertion: false, environment: EnvironmentValues()) {
dispatcher.scoped(environment: environment) {
let value1 = useHook(hook1)
let value3 = useHook(hook3)
let value4 = useHook(hook4)
Expand All @@ -160,7 +166,7 @@ final class HookDispatcherTests: XCTestCase {
let hook2 = TestHook(disposeCounter: disposeCounter)
let hook3 = TestHook(disposeCounter: disposeCounter)

dispatcher.scoped(disablesAssertion: true, environment: EnvironmentValues()) {
dispatcher.scoped(environment: environment) {
// There are 2 hooks
let value1 = useHook(hook1)
let value2 = useHook(hook2)
Expand All @@ -171,7 +177,7 @@ final class HookDispatcherTests: XCTestCase {
XCTAssertEqual(value3, 1)
}

dispatcher.scoped(disablesAssertion: true, environment: EnvironmentValues()) {
dispatcher.scoped(environment: environment) {
// There is 1 hook
let value1 = useHook(hook1)

Expand All @@ -183,7 +189,7 @@ final class HookDispatcherTests: XCTestCase {
XCTAssertEqual(hook3.disposedAt, 1)
XCTAssertEqual(disposeCounter.count, 2)

dispatcher.scoped(disablesAssertion: true, environment: EnvironmentValues()) {
dispatcher.scoped(environment: environment) {
let value1 = useHook(hook1)
let value2 = useHook(hook2)
let value3 = useHook(hook3)
Expand All @@ -193,7 +199,7 @@ final class HookDispatcherTests: XCTestCase {
XCTAssertEqual(value3, 1) // Previous state is initialized
}

dispatcher.scoped(disablesAssertion: true, environment: EnvironmentValues()) {
dispatcher.scoped(environment: environment) {
let value1 = useHook(hook1)
let value2 = useHook(hook2)
let value3 = useHook(hook3)
Expand All @@ -211,7 +217,7 @@ final class HookDispatcherTests: XCTestCase {
let hook2 = TestHook(disposeCounter: disposeCounter)

dispatcher?
.scoped(environment: EnvironmentValues()) {
.scoped(environment: environment) {
_ = useHook(hook1)
_ = useHook(hook2)
}
Expand Down