Abstract
React Hooks are unsuitable for data management tasks due to their reliance on the React rendering cycle. A better alternative can be created using Redux Saga, a flexible system based on generators that can easily chain asynchronous functions and adapt to conditions caused by errors or the contents of API responses. Ultimately, a hybrid of Hooks and Sagas can be used to allow React components to easily submit queries and have the result routed back and used in place.
Elements of this article are a continuation of thoughts recorded here: An approach to caching
Contents
- Abstract
- Contents
- Introduction
- Hooks
- Sagas
- A unified solution
- Deferred queries
- Further enhancements
- Conclusion
Introduction
Having worked with browser-based clients for several years, it has become clear to me that the discipline can be divided quite cleanly into two separate parts: the UI and the data management system. The UI governs the manner in which components are rendered and re-rendered and the conditions for transition from one state to another. The data management system acts as an interface to one or more APIs, converting data into a useable form that can be displayed through the UI.
In a setup using React and Redux, the point where the two worlds meet is the store, an immutable object containing a snapshot of the data used by the application. At a high level, the UI should be a pure function of the store at all times, but there can be small variations governed by local component state.
The data loading process makes changes to the store that are ultimately reflected in the UI. This process needs to take account of different types of objects, errors, and messages that an API might send. It is also often necessary to construct a chain of requests, governed by some conditional logic, to control an incremental process on the server, such as authentication.
In recent times, the standard way of writing a React application has become dominated by the new Hooks feature. This is understandable. Hooks control the flow of an application in a way that closely matches the way React renders. The logic you write is a good analogue of what's really happening. Sadly, this logic is not appropriate for the data side of an application, and the confusion this causes can lead to a lot of errors and inefficiency.
Hooks
Since React 16.8, the Hooks feature has become widely used due to its (mostly) intuitive enhancements of functional components, but there are some places where it is not adequate. A good example is the task of loading data. Hooks are tied to the rendering cycle of React components which gives them a couple of disadvantages.
Firstly, the rendering cycle can happen for any reason anywhere in the application. Consequently, render functions should be free from side-effects that might be mistakenly repeated. This pattern is great for rendering a UI. Data can flow down through a UI hierarchy to yield a tree of HTML. For example, in the following React component, it is easy to see where data enters and exits and what effect it has on the way through.
const Component = ({ color, ...rest }) => { | |
const colorCode = useMemo(() => getColorCode(color), [color]); | |
return ( | |
<StyledComponent colorCode={colorCode}> | |
<Child {...rest} /> | |
</StyledComponent> | |
); | |
}; |
On the other hand, loading and caching data does not work this way. Its functions are meant to be run once and only once in response to an event. They are often not idempotent, that is to say, their effects can vary from one call to the next. For example: a process for authenticating a client:
1. As the application loads, check the local storage of the browser for an authentication token.
2. If the token is present, attempt to make a request for an updated token.
- If the request succeeds, the user is authenticated.
- If the request fails, set the application state to indicate that manual user authentication is required.
- If the request fails, set the application state to indicate that manual user authentication is required.
3. If the token is not present, set the application state to indicate that manual user authentication is required.
This process can be represented by the following pseudocode:
const performAuthenticationCheck = () => { | |
const existingToken = getTokenFromLocalStorage(); | |
if (!existingToken) { | |
return setUnauthenticatedState(); | |
} | |
const { token, error } = requestUpdatedToken(existingToken); | |
if (error) { | |
return setUnauthenticatedState(); | |
} | |
return setAuthenticatedState(token); | |
}; |
Using hooks, this starts to become very confusing:
const useAuthentication = () => { | |
const [authenticated, setAuthenticated] = useState(false); | |
const [unauthenticated, setUnauthenticated] = useState(false); | |
const { existingToken } = useLocalStorage(); | |
const { token, error } = useRequestUpdateToken(existingToken); | |
// Each useEffect requires a render cycle to carry out its logic | |
// For example, `existingToken` is already `true` before the value | |
// of `unauthenticated` can be set. | |
useEffect(() => { | |
if (!existingToken) { | |
setUnauthenticated(true); | |
} | |
}, [existingToken]); | |
useEffect(() => { | |
if (error) { | |
setUnauthenticated(true); | |
} | |
}, [error]); | |
useEffect(() => { | |
if (token) { | |
setAuthenticated(true); | |
} | |
}, [token]); | |
return { | |
authenticated, | |
unauthenticated, | |
token, | |
}; | |
}; |
This logic is entirely unrelated to the steps of the loading process.
Secondly, it also moves through the process in a very inefficient way. Consider:
1. The first time the hook is run,
existingToken
could be
undefined
due to the internal state of
useLocalStorage
.2. This value would then be passed to
useRequestUpdateToken
, which would need another internal state to handle this case. For example:const useRequestUpdateToken = existingToken => { | |
const dispatch = useDispatch(); | |
const token = useSelector(tokenSelector); | |
const error = useSelector(tokenErrorSelector); | |
useEffect(() => { | |
if (existingToken) { | |
dispatch(fetchUpdatedToken(existingToken))); | |
} | |
}, [existingToken]); | |
return { | |
token, | |
error, | |
}; | |
}; |
This same process is carried out for each hook whose input depends on the output of another. Each of these exchanges forces another render of the component that contains the hook. The render cycle is the only process moving the loading process forward. This is highly inefficient.
Sagas
Redux Saga (redux-saga.js.org) is an extension to the Redux system and is a good solution to the problem with Hooks described above. It works by defining "side-effects", or functions to be run in response to an action dispatched to modify the Redux store. Additionally, it operates using generators, allowing asynchronous functions to be used in a sequence to carry out loading with logic that corresponds exactly to the objective. In saga form, the above example might be written as:
function* performAuthenticationCheckSaga() { | |
const { existingToken } = yield call(getLocalStorageSaga); | |
if (!existingToken) { | |
return put(unauthenticatedAction()); | |
} | |
const { token, error } = yield call(requestUpdatedTokenSaga, existingToken); | |
if (error) { | |
return put(unauthenticatedAction(error)); | |
} | |
return put(authenticatedAction(token)); | |
} |
One less visible aspect of generators as compared to asynchronous functions (a loose wrapper around traditional promises) is the fact that they are "pausable". Their operation can be suspended and their state held until
generator.next()
is called, allowing them to be interleaved efficiently. The saga is triggered as follows:function* saga() { | |
yield takeEvery(PERFORM_AUTHENTICATION_CHECK, performAuthenticationCheckSaga); | |
} |
This can then be added to the Redux Saga middleware, connecting it to the Redux store.
The actions
unauthenticatedAction
and
authenticatedAction
can then modify the Redux state with the data loaded from the server. The data can then be accessed by a React component.A Unified Solution
A unified solution needs to allow a React component to carry out the following:
1. Trigger a request for data
2. Pass this trigger to a Saga
3. Run the Saga and follow its conditional logic
4. Store the result
5. Pass the result back to the same React component
This will require a combination of Hook logic and Saga logic. The most important thing is that sequential logic not be carried out in a hook. The solution consists of the following parts:
1. A general
useQuery
hook that hashes all arguments into a unique identifier that tracks the lifecycle of the request. The hook contains a
dispatch
function and a selector that takes the value of the identifier as an argument.const useQuery = ({ blocked = false, ...parameters }) => { | |
const dispatch = useDispatch(); | |
// this is not memoized since it will change if `parameters` changes for any reason. | |
const queryId = createId(parameters); | |
const query = { queryId, ...parameters }; | |
// On the first render, this will contain no data. | |
const queryResult = useSelector(queryResultSelector)(queryId); | |
// Each time the queryId changes, it is a new request, so the useEffect will trigger again. | |
// The `blocked` parameter allows the `useQuery` hook to be nested. | |
useEffect(() => { | |
if (!blocked) { | |
dispatch(queryAction(query)); | |
} | |
}, [blocked, queryId]); | |
return queryResult; | |
}; |
2. A store section indexed by the query ID to update the state of the query.
{ | |
data: { | |
query: { | |
"<queryId>": { | |
registered: true, | |
parameters: { | |
... | |
}, | |
resolved: false, | |
resolution: null | |
} | |
} | |
} | |
} |
3. A saga that is triggered by a query action. It takes the contents of the action, dispatches a request, and dispatches a second action to store the result attached to the query ID.
function* querySaga(action) { | |
const { queryId, ...parameters } = action.payload; | |
const { url, method, headers, body } = parameters; | |
const { response, error } = yield fetch(url, { method, headers, body }); | |
// This logic can be as complex as needed to interact with a specific API | |
yield put(resolveQueryAction({ response, error })); | |
} |
Deferred Queries
Making use of the
blocked
parameter, queries can be deferred. The
blocked
parameter can be kept in a state and changed in response to a function call as follows:const useDeferredQuery = parameters => { | |
const [blocked, setBlocked] = useState(true); | |
const [queryParameters, setQueryParameters] = useState(parameters); | |
const queryResult = useQuery({ blocked, ...queryParameters }); | |
const onTrigger = useCallback(deferredParameters => { | |
setQueryParameters({ ...queryParameters, ...deferredParameters }); | |
setBlocked(false); // this allows the deferred hook to run | |
}, [parameters]); | |
return { | |
result: queryResult, | |
onTrigger, | |
}; | |
}; |
The hook can then be used in a React component:
const Component = ({ payload }) => { | |
const { result, onTrigger } = useDeferredQuery(); | |
const renderResult = () => { | |
if (!result) { | |
return 'Loading...'; | |
} | |
return result; | |
}; | |
return ( | |
<StyledComponent> | |
<Child content="Dispatch" onClick={() => onTrigger(payload)} /> | |
{renderResult()} | |
</StyledComponent> | |
); | |
}; |
Further Enhancements
The data cycle presented above can be enhanced further by dividing resources into "APIs", "Sockets", and "Models". These can take the form of classes that store information as simple as URLs and field names and as complicated as functions run on received data to modify it before it is stored.
API
The API class should be instantiated once when starting the application. Its methods can be invoked when running Hooks and Sagas in order to modify requests with a URL or default headers.
class API { | |
createSocket() { | |
this.socket = new WebSocket(this.url); | |
// OR | |
this.socket = new CustomSocket(this.url); | |
} | |
} |
SOCKET
A socket instance can be added as a property of an
API
object. It can allow an API to interact with a server using a common interface. The socket transforms the requests that are ultimately dispatched into forms accepted by REST APIs, Websockets, or even
localstorage
and browser address bar routing for consistency. All data from requests and responses are stored using Redux and can be queried from there rather than directly querying these data sources.class Socket { | |
receive(data) { | |
const transformedData = extractWebsocketFrame(data); | |
// This data is passed to the store as part of the query resolution. | |
return { | |
data: transformedData, | |
}; | |
} | |
} |
MODEL
A Model represents a resource from the server, such as User or Token. Class methods can be used to append specific URL routing, query parameters, or headers to a request. The model can also control the structure of any POST body sent to create or update resources. It can contain client-side validation, preventing a request from being dispatched until certain conditions are met. Resource-specific code can be placed inside the model class. Instantiated subclasses of Model can serve as object representations of resource instances. They can even be given the ability to dispatch instance-specific requests using instance methods.
class Model { | |
constructor(data) { | |
const { id, attributes, relationships } = data; | |
this.id = id; | |
this.attributes = attributes; | |
this.relationships = relationships; | |
} | |
get name() { | |
const { name } = this.attributes; | |
return name; | |
} | |
} |
Conclusion
In conclusion, React Hooks are unsuitable for API logic due to the mismatch between the their data flow pattern and the linear conditional logic of API requests. A solution can be found by creating a hybrid between Hooks and linear logic using Redux Saga. Asynchronous functions can be chained to create a logical process that runs independently of the unpredictable React rendering environment.