Hook Is All You Need (HIAYN) is a design pattern for React developers to manage complex state without needing to learn any third-party state management libraries.
HIAYN offers a 0-API, 0-learning curve, 0-boilerplate solution that is performant, DevTools-powered (with time travel and logging), supports async flow, and provides compositional, immutable, multi-store state management. It is also test-friendly and 100% TypeScript.
Have you ever wondered why React developers often have to learn third-party state management libraries after mastering React hooks? The philosophy behind these libraries often diverges significantly from React itself, making it feel like you're not using React anymore. But do we really need them?
No, we don’t. React’s built-in hooks, such as useState
, useCallback
, and useContext
, are powerful enough to manage even the most complex state. The challenge is that the design patterns are not well-structured and lack DevTools support.
HIAYN's Interceptor allows you to view state changes from any useState
hook, along with the triggering useCallback
, in Redux DevTools.
You can even click the "Jump" button on an action to time travel to that state.
Here’s the example:
import { useState, useCallback } from "hook-is-all-you-need";
function useCounter(base: number) {
const [count, setCount] = useState(0);
const increment = useCallback(
(mul: number) => setCount((count) => count + base * mul),
[base]
);
return { count, increment };
}
function Counter({ base }: { base: number }) {
const counter = useCounter(base);
const { count, increment } = counter;
return (
<div>
<div>Count: {count}</div>
<button onClick={() => increment(3)}>increment</button>
</div>
);
}
Let's explore it in depth
useCallback is a Redux action.
type: "@Example.c10|Counter.counter.increment";
args: [3];
deps: [10];
-
@Example.c10|
refers to the scopes, which we’ll discuss later -
Counter
is the function component name -
counter
is the variable name of the hook call (const counter = useCounter
) -
increment
is the callback variable name (const increment = useCallback
) -
args
are the arguments of theincrement
callback ((mul: number)
) -
deps
are the dependencies of theincrement
callback ([base]
)
useState is a Redux reducer
@Example.c10|Counter.counter.count: 0 => 30
-
@Example.c10|
,Counter
,counter
are the same as above -
count
is the state variable name (const [count, setCount] = useState
) - The state changed from
0
to30
.
The Counter
component is used in App
:
import { ScopeProvider } from "hook-is-all-you-need";
function App() {
return (
<ScopeProvider scope="Example">
<ScopeProvider scope="c10">
<Counter base={10} />
</ScopeProvider>
<ScopeProvider scope="c100">
<Counter base={100} />
</ScopeProvider>
</ScopeProvider>
);
}
In @Example.c10|
, Example
is from the parent ScopeProvider
, and c10
is from the child ScopeProvider
.
Scopes help distinguish multiple instances of the same component and are concatenable.
Scopes are not necessary if your component names are unique.
npm i hook-is-all-you-need
Initialize Interceptor
and wrap InterceptorContext
around your root component.
import { Interceptor, InterceptorContext } from "hook-is-all-you-need";
const interceptor = new Interceptor({
enabled: process.env.NODE_ENV === "development",
});
root.render(
<InterceptorContext.Provider value={interceptor}>
<App />
</InterceptorContext.Provider>
);
And use useState
and useCallback
from the hook-is-all-you-need
package instead of react
, as demonstrated in the example code above.
import { useState, useCallback } from "hook-is-all-you-need";
To not manually modify react
to hook-is-all-you-need
, include the following at the top of your application's entry point file, similar to why-did-you-render.
import React from "react";
import { hookIsAllYouNeed } from "hook-is-all-you-need";
hookIsAllYouNeed(React);
// ...
ReactDOM.render(<App />, document.getElementById("root"));
// Then we still import from react
import { useState, useCallback } from "react";
That’s all you need to use HIAYN!
It’s a 0-API, 0-learning curve, 0-boilerplate, DevTools-powered, test-friendly, TypeScript-safe, compositional, immutable and multi-store state management pattern, as promised.
- Custom hooks act as data models or stores in Redux
- Easy to test
- Compositional and supports
extraReducers
by nature
- Callbacks are actions of reducers:
- The callback itself is the action type
-
args
are the action payload -
deps
are static data in the action creator
- Reducers are non-reusable and useless
- Advantages of custom hooks over Redux:
- Custom hooks allow for testing of computed values directly
- Callbacks as closures can carry payload values
For example, let’s say we have an incrementApi
callback. It sets pending
to true, and after the API promise resolves, it updates count
and pending
to false.
function useCounter() {
// ...
const incrementApi = useCallback(async function () {
setPending(true);
const inc = await api(base);
setCount((count) => count + inc);
setPending(false);
}, [base]);
HIAYN Interceptor supports asynchronous operations in callbacks, ensuring state changes are accurately linked to the correct callbacks.
To make it work, for async callbacks, you need to change
-
async function
to a generator functionfunction*
-
await
toyield
.
function useCounter() {
// ...
const incrementApi = useCallback(function* () { // async function -> function*
setPending(true);
const inc = yield api(base); // await -> yield
setCount((count) => count + inc);
setPending(false);
}, [base]);
You can find a fully functional example in the "examples" folder.
Using useState
with custom hooks might seem limited to creating local state, but that's not the case. You can import other custom hooks to form a comprehensive global store, similar to Redux's configureStore
, at any nested depth level.
To avoid performance issues with Context re-rendering, use-context-selector
is needed to use.
PS: Hey React! Just implement the f*cking context selector!
import {
createContext,
useContextSelector as useCs,
} from "use-context-selector";
import useAuth from "useAuth";
import useTodos from "useTodos";
import useSettings from "useSettings";
import useDialog from "useDialog";
function useStore() {
return {
auth: useAuth(),
todos: useTodos(),
settings: useSettings(),
dialog: useDialog(),
};
}
const StoreContext = createContext(undefined);
const StoreProvider = ({ children }) => (
<StoreContext.Provider value={useStore()}>{children}</StoreContext.Provider>
);
function TodosApp() {
const todos = useCs(StoreContext, (s) => s.todos);
}
However, in my opinion, a single store is an anti-pattern. Single store is difficult to structure and manage in both small and large applications.
A better approach is to create a Context for each custom hook and use it directly. You can even create custom hooks to manipulate multiple data sources. This approach represents true composition, unlike Redux's extraReducers
.
function Auth() {
const hasAuth = useCs(AuthContext, (s) => s.hasAuth);
}
function TodosHeader() {
const addTodo = useCs(TodoContext, (s) => s.addTodo);
}
function useLogout() {
const clearAuth = useCs(AuthContext, (s) => s.clearAuth);
const clearTodos = useCs(TodoContext, (s) => s.clearTodos);
return useCallback(() => {
clearAuth();
clearTodos();
}, [clearAuth, clearTodos]);
}
Provider hell can be a problem, but react-nest
can easily solve it. It's much simpler than Redux, isn't it?
import Nest from "react-nest";
function Root() {
return (
<Nest>
<AuthProvider />
<TodosProvider />
<SettingsProvider />
<DialogProvider />
<App />
</Nest>
);
}
As our React app grows larger, props drilling becomes a significant issue. Props drilling violates the principle of Separation of Concerns (SOC), where a parent component accepts a prop to pass to its child component without directly using it.
Note that Redux, Zustand, and Jotai do not entirely solve props drilling for sub and non-single data. While they improve performance by re-rendering only when subscribed data changes, we still need to pass some data, like an id, index or key, to deeply nested child components.
React Context is already a robust Dependency Injection system which can perpermanently solve props drilling.
First, we need to regard custom hook as a data model. For instance, consider a useTodos
custom hook.
export function useTodos() {
const [todos, updateTodos] = useImmer([]);
const addTodo = useCallback(/* ... */);
return { todos, updateTodos, addTodo };
}
export const TodosCtx = createContext(undefined);
Avoid doing this:
export function TodoList() {
const todosModel = useTodos();
const { todos, addTodo } = todosModel;
return (
<div>
<button onClick={addTodo}>Add</button>
<TodosCtx.Provider value={todosModel}>
{todos.map((todo) => <TodoItem todo={todo} />)}
</TodosCtx.Provider>
</div>
);
}
export function TodoItem({ todo }) {
const updateTodos = useCs(TodosCtx, (ctx) => ctx.updateTodos);
const updateTitle = (title) => updateTodos(todos => /* ... */);
}
Instead, do this:
export function TodoList() {
return (
<TodosCtx.Provider value={useTodos()}>
<_TodoList />
</TodosCtx.Provider>
);
}
function _TodoList() {
const todos = useCs(TodosCtx, (ctx) => ctx.todos);
const addTodo = useCs(TodosCtx, (ctx) => ctx.addTodo);
return (
<div>
<button onClick={addTodo}>Add</button>
{todos.map((todo) => (
<TodoItem todo={todo} />
))}
</div>
);
}
In this pattern, TodoList
acts as a Provider component, and _TodoList
is a Render component.
The benefits include:
-
_TodoList
doesn't need to know how or where the TodosCtx value is created; it just uses it. - If we need to lift the state up, we only need to move
<TodosCtx.Provider>
up. - "Provider hell" is contained within Provider components and doesn't clutter the rendering code.
In Redux, we still need to pass the todo itself or its id to a deeply nested child component and use the id in the dispatched action.
export function TodoItem({ todo }) {
const update = (title) => dispatch(updateTitle(todo.id, title));
}
export const todosSlice = createSlice({
name: "todos",
initialState: { todos: [] },
reducers: {
updateTitle: (state, action) => {
const { id, title } = action.payload;
const todo = state.todos.find((todo) => todo.id === id);
todo?.title = title;
},
},
});
React Context used as a Dependency Injection can do this much elegantly.
Define the useTodo
hook as the Todo data model.
export function useTodo(id: number) {
const updateTodos = useCs(TodosCtx, ({ updateTodos }) => updateTodos);
const updateTodo = (draft) =>
updateTodos((todos) => {
const index = todos.findIndex((todo) => todo.id === id);
draft(todos[index]);
});
return { todo, updateTodo };
}
export const TodoCtx = createContext(undefined);
And the TodoItem
component looks like:
export function TodoItem({ id }) {
return (
<TodoCtx.Provider value={useTodo(id)}>
<_TodoItem />
</TodoCtx.Provider>
);
}
function _TodoItem() {
const update = useCs(TodoCtx, (ctx) => ctx.updateTodo);
}
With this approach, any deeply nested child components of TodoItem
can access the useTodo
data model without deeply passing props and while remaining performant.
You can find a fully working todo example in the "examples/src/todomvc" folder.
Although jotai-scope, unstated-next and constate can achieve the same functionality, they do not view React Context as a powerful DI pattern.
The above pattern has 2 small issues:
- It requires boilerplate code to create a Context, Provider, and use a shortcut hook for a custom hook.
function useTodo(id) {}
const TodoCtx = createContext(undefined);
function TodoProvider({ id, children }) {
return <TodoCtx.Provider value={useTodo(id)}>{children}</TodoCtx.Provider>;
}
const useTodo = (selector) => useCs(TodosCtx, selector);
- Context providers must be in the correct order; otherwise, the context can't be found.
HIAYN library provides a much handier way to solve the above issues with minimal APIs. Yes, I promised there wouldn't be new APIs. Think of this more as syntax sugar rather than new APIs. Underneath, it's almost the same as writing it by hand.
Let's say we have 3 custom hooks: useApi
uses the context of useHttp
, and useTodos
uses useApi
.
function useHttp() {}
function useApi() {
const fetch = useContextSelector(HttpContext, (c) => c.fetch);
}
function useTodos() {
const api = useContextSelector(ApiContext, (c) => c.api);
}
function TodoApp() {
return (
<Nest>
<HttpProvider />
<ApiProvider />
<TodosProvider />
<App />
</Nest>
);
}
With HIAYN's Dependency Injection, you can do it like this:
function useHttp() {}
function useApi() {
const fetch = useInject(useHttp, (c) => c.fetch);
}
function useTodos() {
const api = useInject(useApi, (c) => c.api);
}
export function TodoApp() {
return (
<Injector providers={[useApi, useHttp, useTodos]}>
<_TodoApp />
</Injector>
);
}
No need to create a Context and Provider. A custom hook itself is an identifiable context, and custom hooks can be provided directly.
Note that the order of custom hooks in providers
can be arbitrary. It will automatically figure out the correct order and will throw an error if a circular dependency is detected.
If the custom hook needs arguments, its provider is a tuple of the hook and a factory function.
export function TodoApp(endpoint) {
return (
<Injector
providers={[
useHttp,
[useApi, () => useApi(endpoint)], // <-- [hook, factory] tuple
useTodos,
]}
>
<_TodoApp />
</Injector>
);
}
You can find the example in the "examples/src/di" folder.