A lightweight, zero-dependency, type-safe state management library for React.
redux-lite
offers a modern, simple, and highly performant state management solution, designed to provide an excellent developer experience with TypeScript. Unit testing your components is now unimaginably easy.
- 🚀 Zero-Dependency: Extremely lightweight with no third-party runtime dependencies (only
react
as a peer dependency). - ⚡️ High Performance: Avoids unnecessary re-renders by design through smart value comparisons.
- ✨ Simple & Intuitive API: A minimal API that is easy to learn and use.
- 🔒 Fully Type-Safe: End-to-end type safety, from store definition to dispatchers, with excellent autocompletion.
- ✅ Unbelievably Easy Testing: A flexible provider makes mocking state for unit tests trivial.
- 🐞 DevTools Ready: Optional, zero-cost integration with Redux DevTools for a great debugging experience.
- 🔌 Middleware Support: Extend functionality with custom middlewares, similar to Redux.
npm install @oldbig/redux-lite
# or
yarn add @oldbig/redux-lite
# or
pnpm add @oldbig/redux-lite
Create a storeDefinition
object. This single object is the source of truth for your entire state structure and types.
// store.ts
import { initiate, optional } from '@oldbig/redux-lite';
export const STORE_DEFINITION = {
user: {
name: 'Jhon' as string | null,
age: 30,
},
// Use `optional` for state slices that might not exist
task: optional({
id: 1,
title: 'Finish redux-lite',
}),
counter: 0,
};
export const { ReduxLiteProvider, useReduxLiteStore } =
initiate(STORE_DEFINITION);
In your main application file, wrap your component tree with the ReduxLiteProvider
.
// main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { ReduxLiteProvider } from './store';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ReduxLiteProvider>
<App />
</ReduxLiteProvider>
</React.StrictMode>,
);
Use the useReduxLiteStore
hook to access state slices and their corresponding dispatchers. The hook returns a flattened object containing all state properties and type-safe dispatcher functions.
// MyComponent.tsx
import { useReduxLiteStore } from './store';
const MyComponent = () => {
// Destructure state and dispatchers
const {
user,
counter,
dispatchUser,
dispatchPartialUser,
dispatchCounter
} = useReduxLiteStore();
return (
<div>
<h2>User: {user.name}</h2>
<p>Counter: {counter}</p>
{/* Full update */}
<button onClick={() => dispatchUser({ name: 'Ken', age: 31 })}>
Set User
</button>
{/* Partial update */}
<button onClick={() => dispatchPartialUser({ age: 35 })}>
Update Age
</button>
{/* Functional update with access to the full store */}
<button onClick={() => dispatchPartialUser((currentUser, store) => ({ age: currentUser.age + store.counter }))}>
Increment Age by Counter
</button>
</div>
);
};
The sole entry point for the library.
storeDefinition
: An object that defines the shape and initial values of your store.options
(optional): An object for additional configuration.devTools
(optional):boolean | { name: string }
- Enable or configure Redux DevTools.middlewares
(optional):Middleware[]
- An array of middlewares to apply.
- Returns: An object containing
{ ReduxLiteProvider, useReduxLiteStore, useSelector }
.
The hook returns a flattened object containing all state slices and dispatchers.
Dispatchers
For each slice of state (e.g., user
), two dispatchers are generated:
dispatchUser(payload)
: For full updates.dispatchPartialUser(payload)
: For partial updates.
The payload
can be a value or a function. If it's a function, it receives the previous state of that slice as the first argument, and the entire store state as the second argument: (prevState, fullStore) => newState
.
A helper function to mark a state slice as optional. The state property will be typed as T | undefined
.
initialValue
(optional): The initial value of the property. If not provided, the state will beundefined
.
A hook for selecting and subscribing to a part of the state, with performance optimizations. It is similar to the useSelector
hook in react-redux
.
selector
:(store: TStore) => TSelected
- A function that takes the entire store state and returns the selected value.equalityFn
(optional):(a: TSelected, b: TSelected) => boolean
- A function to compare the selected value. Defaults toisEqual
(a deep equality check). If the selector function returns the same result as the previous call (determined by this equality function),useSelector
will return the previous result, which can help prevent unnecessary re-renders in the component that uses it. In most cases, you don't need to provide this parameter. It's only necessary if the value returned by theselector
contains function fields.
When to use useSelector
?
While useReduxLiteStore
is convenient for accessing both state and dispatchers, useSelector
is highly recommended for performance-critical components that only need to read a small piece of state. It helps prevent unnecessary re-renders when other parts of the store change.
Example:
import { useSelector } from './store';
const UserName = () => {
// This component will only re-render when `user.name` changes.
const userName = useSelector(store => store.user.name);
return <div>{userName}</div>
}
const UserAge = () => {
// This component will only re-render when `user.age` changes.
const userAge = useSelector(store => store.user.age);
return <div>{userAge}</div>
}
redux-lite
handles asynchronous operations gracefully and simply by leveraging standard JavaScript async/await
syntax combined with the functional update form of its dispatchers. This approach is intuitive, robust, and requires no new APIs to learn.
Recommended Pattern:
- Use
async/await
to handle your asynchronous logic (e.g., fetching data). - Call the synchronous
dispatch
function with an updater function to apply the result. This ensures you are always working with the latest state, avoiding race conditions.
Example: Fetching a user and updating the store
import { useReduxLiteStore } from './store';
import { api } from './api';
const UserComponent = () => {
const { user, dispatchUser, dispatchPartialUser } = useReduxLiteStore();
const handleFetchUser = async () => {
try {
// 1. Await the data from your API
const fetchedUser = await api.fetchUser(123);
// 2. Dispatch the result to fully update the user slice
dispatchUser(fetchedUser);
} catch (error) {
console.error("Failed to fetch user:", error);
}
};
const handleIncrementUserAge = async () => {
try {
// 1. Await the change from your API
const { ageIncrement } = await api.fetchAgeIncrement(123); // e.g., returns { ageIncrement: 1 }
// 2. Use a functional update and return only the partial object.
// redux-lite will automatically merge this with the existing user state.
dispatchPartialUser(currentUser => ({
age: currentUser.age + ageIncrement,
}));
} catch (error) {
console.error("Failed to increment user age:", error);
}
};
return (
<div>
<p>Current User: {user.name}</p>
<button onClick={handleFetchUser}>Fetch User</button>
<button onClick={handleIncrementUserAge}>Increment User Age</button>
</div>
);
};
This pattern is clean, easy to test, and leverages the full power of redux-lite
's type-safe, functional dispatchers without adding any complexity.
redux-lite
is designed for high performance. The internal reducer uses smart value comparison to prevent state updates and re-renders when data has not changed.
In a benchmark test that simulates a real-world scenario by calling a dispatch function repeatedly, redux-lite
was able to perform:
- 10,000 Counter Updates in approximately 16.43 milliseconds (0.0016ms per update)
- 1,000 Array Push Operations in approximately 3.9 milliseconds (0.0040ms per operation)
- 10,000 Object Property Updates in approximately 15.48 milliseconds (0.0015ms per update)
- 10,000 Partial Object Updates in approximately 15.15 milliseconds (0.0015ms per update)
- 1,000 Deeply Nested Updates in approximately 3.42 milliseconds (0.0034ms per update)
This demonstrates its exceptional speed even when including React's rendering lifecycle.
Feature | Redux (with Redux Toolkit) | redux-lite |
---|---|---|
Boilerplate | Requires createSlice , configureStore , actions, reducers. |
Almost zero. Define one object, get everything you need. |
API Surface | Larger API with multiple concepts (slices, thunks, selectors). | Minimal. initiate , optional , and the returned hook. |
Type Safety | Good, but can require manual typing for thunks and selectors. | End-to-end. Types are automatically inferred for everything. |
Performance | Highly performant, but relies on memoized selectors (reselect ). |
Built-in. Automatically prevents updates if values are deeply equal. |
Dependencies | @reduxjs/toolkit and react-redux . |
None. Only react as a peer dependency. |
Simplicity | Steeper learning curve. | Extremely simple. If you know React hooks, you know redux-lite . |
Testing Your Components
redux-lite
makes testing components that use the store incredibly simple. The ReduxLiteProvider
accepts an initStore
prop, which allows you to provide a deep partial state to override the default initial state for your tests.
This means you don't need to dispatch actions to set up your desired test state. You can directly render your component with the exact state it needs.
Here's how you can easily mock state for your components:
import { render } from '@testing-library/react';
import { initiate } from '@oldbig/redux-lite';
import React from 'react';
// Assume this is your initial store configuration
const STORE_DEFINITION = {
user: { name: 'Guest', age: 0, profile: { theme: 'dark' } },
isAuthenticated: false,
};
const { ReduxLiteProvider, useReduxLiteStore } = initiate(STORE_DEFINITION);
// --- Your Component ---
const UserProfile: React.FC = () => {
const { user } = useReduxLiteStore();
return <div>Welcome, {user.name} (Theme: {user.profile.theme})</div>;
};
// --- Your Test ---
it('should display the authenticated user name with overridden profile', () => {
const { getByText } = render(
<ReduxLiteProvider initStore={{ user: { name: 'Alice', profile: { theme: 'light' } }, isAuthenticated: true }}>
<UserProfile />
</ReduxLiteProvider>
);
// The component renders with the exact state you provided
expect(getByText('Welcome, Alice (Theme: light)')).toBeInTheDocument();
});
it('should shallow merge user slice and replace nested objects', () => {
const { getByText } = render(
<ReduxLiteProvider initStore={{ user: { name: 'Bob' } }}>
<UserProfile />
</ReduxLiteProvider>
);
// user.name is overridden, user.age remains default, user.profile is untouched
expect(getByText('Welcome, Bob (Theme: dark)')).toBeInTheDocument();
});
You can easily test your components in different states without any complex setup or mocking.
DevTools Integration
redux-lite
offers optional integration with the Redux DevTools Extension for a first-class debugging experience, including action tracking and time-travel debugging.
This feature is disabled by default and has zero performance cost when not in use.
How to Enable
To enable the integration, pass the devTools
option to the initiate
function.
// Enable with default options
const { ReduxLiteProvider, useReduxLiteStore } = initiate(STORE_DEFINITION, {
devTools: true
});
// Or provide a name for your store instance and other options
const { ReduxLiteProvider, useReduxLiteStore } = initiate(STORE_DEFINITION, {
devTools: {
name: 'MyAppStore',
maxAge: 50, // Limit the number of actions to store
latency: 500 // Batch actions with a 500ms delay
}
});
Installation
- Install the Redux DevTools Extension for your browser:
- Enable the feature in your code as shown above.
- Open your browser's developer tools and find the "Redux" tab.
Middleware
redux-lite
supports a middleware API that is almost identical to Redux's, allowing you to extend the store's capabilities for logging, handling async actions, and more.
How to Use Middleware
Pass an array of middlewares in the options
object when calling initiate
.
import { initiate, Middleware } from '@oldbig/redux-lite';
const logger: Middleware<any> = (api) => (next) => (action) => {
console.log('dispatching', action);
const result = next(action);
console.log('next state', api.getState());
return result;
};
const { ReduxLiteProvider, useReduxLiteStore } = initiate(STORE_DEFINITION, {
middlewares: [logger]
});
Writing Custom Middleware
A middleware is a higher-order function with the following signature:
type Middleware<S> = (api: MiddlewareAPI<S>) => (next: (action: Action<S>) => Action<S>) => (action: Action<S>) => Action<S>;
api
: An object with two methods:getState()
: Returns the current state.dispatch(action)
: Dispatches an action. This will send the action to the start of the middleware chain.
next
: A function that passes the action to the next middleware in the chain. You must callnext(action)
at some point for the action to eventually reach the reducer.action
: The action being dispatched.
Important Middleware Best Practices
- Avoid Infinite Loops: Calling
api.dispatch(action)
within a middleware sends the action back to the beginning of the middleware chain. To prevent infinite loops, always placeapi.dispatch
calls within appropriate conditional blocks:
const conditionalDispatchMiddleware: Middleware<any> = (api) => (next) => (action) => {
// BAD - This will cause an infinite loop
// api.dispatch({ type: 'someAction', payload: 'data', isPartial: false });
// GOOD - Place dispatch in a conditional block
if (action.type === 'user_login') {
api.dispatch({ type: 'notifications_show', payload: 'Welcome!', isPartial: false });
}
return next(action);
};
-
Error Handling: Wrap middleware logic in try-catch blocks to prevent one faulty middleware from breaking the entire chain.
-
Performance: Minimize heavy computations in middlewares as they run synchronously and can block the UI thread.
- Todo List App - A complete todo list application demonstrating core features
- Performance Test - Performance benchmarks demonstrating the efficiency of redux-lite
If you find redux-lite
helpful and would like to support its development, please consider:
- Giving a ⭐️ on GitHub
- Buying me a coffee
Your support is greatly appreciated!
This project is licensed under the MIT License.