At Perlin, we want to make creating Dapps as easy as possible. In our previous chat Dapp example, we had to write around 100 lines of connector glue in order to connect to the network, get the account status, and load a contract.
As the example was written with React Hooks, extracting that glue into re-usable hooks was easy, and allows us to connect to Wavelet in a reactive way with only 3 lines of code.
Here is a full example that allows you to “Yo” on the blockchain.
Much simpler than the 300 lines in the previous example, right?
This highlights a key strength in the design of React Hooks. If we wrote the logic as a traditional component we would have to build a number of HOCs and nest our actual business logic component deep within this stack.
If we want to use multiple components, clients, or accounts, we would have to restructure this nesting.
Components would also impose interface requirements on their children; require messy render callback code, or use React Context which can make components less reusable as it creates an implicit dependency on a parent component.
Here’s how it would look like if we used any of those approaches:
This assumes that we always pass down the client to all children components, and creates messy intermediate functions to pass injected dependencies to the base component.
This example is similar to the HOC example, and is more explicit in how dependencies get passed. Having two different forms of nesting in your component gets ugly quick.
The context example looks the cleanest, but mainly because we hide the dependencies being passed down in a context object. Now
MyComponent has to ensure it only updates when relevant parts of the context data has changed, and is tightly coupled to its parent component.
As we can see, all of these approaches have downsides and different implementation concerns.
The React Hooks approach is self contained, yet composed of reusable elements, simplifying the amount of dependency plumbing that would otherwise be in JSX.
There are a concerns to keep in mind when writing hooks however:
- Preventing expensive code from running on every render
- Dependency loops / infinite renders
- Error handling
When writing data connectors, the most expensive functions are generally those that call the external services.
In our case when we verify that the client we created is valid by fetching the node info,
… when we fetch account details and register its live update socket,
… and when we initialize contracts:
These actions should always be wrapped in
useEffect hooks, which will cause them to only render when a value in their dependency array changes. In order to avoid subtle bugs, all values used in the effect callback need to be present in the dependency array, and the handy
eslint-plugin-react-hooks ensures that you do this.
If you follow the
eslint-plugin-react-hooks rule, but do not pay attention to the variables being used, you may end up including values that get set by the effect itself, leading to infinite renders.
There are even more subtle cases of these dependency loops, where a component that uses a hook provides a value to that hook that gets updated when the hook returns a value eg. say we wanted to build a component that only counts when we get permission from the backend.
Here you don’t immediately see the dependency loop, as the only state change in the effect is
permission, which isn't a dependency, but we still end up with an infinite loop as
count's value will change on every render, which in turn will cause a the
usePermissionedCounter to trigger, which will cause
MyComp to re-render, starting the cycle again.
While this example seems a bit strained, we experienced it when writing a hook that takes a callback to handle live updates. The solution is to ensure that callbacks being passed to hooks are always wrapped in a
useCallback hook, with appropriate dependency arrays. Would be pretty neat if we could have an
eslint rule for this as well!
Error handling in async hooks is another consideration, as you cannot simply
catch or use React Error Boundaries.
Instead, you should always provide an
error value in the result array if any of the promises in your async code can be rejected. You can then check whether the error is not-null and handle it in the components that use it.
Also remember to unset previous error / result values correctly, otherwise you may end up never recovering from an error or seeing old values from a hook that has subsequently failed!
Testing React hooks is relatively simple, using
@testing-library/react-hooks. A quick example would be
Writing tests also gives you deeper insights into exactly how your component behaves, that you may not directly see in your app, for example, the implicit requirement that you need to wait for the async hook to execute may not be obvious if the promise resolves immediately.
You can also identify inefficient renders when you need to
waitForNextUpdate multiple times to achieve the desired state.
Overall, extracting the hooks has resulted in much more concise code, with behaviour that can be tested and made more robust without having to change our app’s implementation.
You definitely have to understand reactive cycles in greater depth to avoid critical errors, but it is generally better practise to have apps that are clearly broken, instead of subtly broken, and Hooks will expose these issues much quicker.
The resulting package is available at:
Originally published at http://github.com.