State management in React often starts with useState
and evolves into complex global solutions like Redux. But what if you could solve common state headaches with less boilerplate and more elegance? After working on dozens of React codebases, I’ve curated these 5 unconventional state management hacks that will transform how you handle data flow. Bonus: All examples use TypeScript for type safety!
1. Observable Patterns with RxJS (Without Redux)
The Problem: Sharing state between unrelated components without prop drilling
The Hack: Create a lightweight observable store
// observable-store.ts
import { BehaviorSubject } from 'rxjs';
type User = { id: string; name: string };
const user$ = new BehaviorSubject<User | null>(null);
export const userStore = {
setUser: (user: User) => user$.next(user),
getUser: () => user$.value,
subscribe: (callback: (user: User | null) => void) => {
const subscription = user$.subscribe(callback);
return () => subscription.unsubscribe();
}
};
// ComponentA.tsx
const ComponentA = () => {
const [user, setUser] = useState<User | null>(userStore.getUser());
useEffect(() => {
return userStore.subscribe(setUser);
}, []);
return <div>{user?.name}</div>;
};
// ComponentB.tsx
const updateUser = () => {
userStore.setUser({ id: '1', name: 'New Name' });
};
Why It Works:
- Zero dependencies (or add RxJS for advanced operators)
- Type-safe subscriptions with TypeScript generics
- Decoupled components communicate via events
Pro Tip: Wrap this pattern in a custom hook for reusability:
const useObservable = <T,>(observable: BehaviorSubject<T>) => {
const [value, setValue] = useState<T>(observable.value);
useEffect(() => {
const sub = observable.subscribe(setValue);
return () => sub.unsubscribe();
}, [observable]);
return value;
};
2. URL-as-State: Sync State with Query Parameters
The Problem: Losing state on page refresh or sharing app state
The Hack: Store state in the URL with react-router
and TypeScript
// useUrlState.ts
import { useSearchParams } from 'react-router-dom';
const useUrlState = <T extends Record<string, string>>(defaultState: T) => {
const [searchParams, setSearchParams] = useSearchParams();
const state = useMemo(() => ({
...defaultState,
...Object.fromEntries(searchParams)
}), [searchParams]);
const setState = (newState: Partial<T>) => {
setSearchParams({ ...state, ...newState }, { replace: true });
};
return [state as T, setState] as const;
};
// Usage.tsx
const Filters = () => {
const [filters, setFilters] = useUrlState({
sort: 'price',
category: 'all',
page: '1'
});
return (
<select
value={filters.sort}
onChange={(e) => setFilters({ sort: e.target.value })}
>
<option value="price">Price</option>
<option value="rating">Rating</option>
</select>
);
};
Benefits:
- State survives page refreshes
- Shareable app state via URL
- Built-in history tracking
3. Atomic State with Jotai-like Patterns
The Problem: Over-engineering global state for small reactive values
The Hack: Create atomic state units with minimal boilerplate
// atoms.ts
import { atom } from 'jotai'; // Or implement your own lightweight version
export const counterAtom = atom(0);
export const doubledCounterAtom = atom((get) => get(counterAtom) * 2);
// CounterComponent.tsx
const Counter = () => {
const [count, setCount] = useAtom(counterAtom);
return (
<button onClick={() => setCount((c) => c + 1)}>
Count: {count}
</button>
);
};
// DisplayComponent.tsx
const Display = () => {
const [doubled] = useAtom(doubledCounterAtom);
return <div>Doubled: {doubled}</div>;
};
Custom Atom Implementation (No Library):
type Atom<T> = {
get: () => T;
set: (newValue: T) => void;
subscribe: (listener: () => void) => () => void;
};
const createAtom = <T extends unknown>(initialValue: T): Atom<T> => {
let value = initialValue;
const listeners = new Set<() => void>();
return {
get: () => value,
set: (newValue) => {
value = newValue;
listeners.forEach((listener) => listener());
},
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
}
};
};
4. State Machines for Complex UI Flows
The Problem: Spaghetti code in multi-step forms/processes
The Hack: Implement finite state machines with TypeScript enums
enum UploadState {
IDLE = 'idle',
UPLOADING = 'uploading',
SUCCESS = 'success',
ERROR = 'error'
}
type UploadMachine = {
state: UploadState;
file: File | null;
error: string | null;
};
const uploadReducer = (
state: UploadMachine,
action: { type: 'START' | 'SUCCESS' | 'ERROR'; file?: File; error?: string }
): UploadMachine => {
switch (action.type) {
case 'START':
return { ...state, state: UploadState.UPLOADING, file: action.file || null };
case 'SUCCESS':
return { ...state, state: UploadState.SUCCESS, error: null };
case 'ERROR':
return { ...state, state: UploadState.ERROR, error: action.error || null };
default:
return state;
}
};
const FileUploader = () => {
const [state, dispatch] = useReducer(uploadReducer, {
state: UploadState.IDLE,
file: null,
error: null
});
// Usage in async operations
const handleUpload = async (file: File) => {
dispatch({ type: 'START', file });
try {
await api.upload(file);
dispatch({ type: 'SUCCESS' });
} catch (error) {
dispatch({ type: 'ERROR', error: error.message });
}
};
};
Why This Rocks:
- Explicit state transitions
- Impossible to enter invalid states
- Self-documenting code with enums
5. Type-Safe Context with Selectors
The Problem: Context API causing unnecessary re-renders
The Hack: Combine context with Zustand-like selectors
// createSafeContext.ts
import { createContext, useContext, useMemo } from 'react';
const createSafeContext = <T extends unknown>() => {
const Context = createContext<T | undefined>(undefined);
const useSafeContext = <S extends unknown>(
selector: (context: T) => S
): S => {
const context = useContext(Context);
if (!context) throw new Error('Missing provider!');
return useMemo(() => selector(context), [context, selector]);
};
return [Context.Provider, useSafeContext] as const;
};
// ThemeContext.ts
type ThemeState = {
theme: 'light' | 'dark';
toggleTheme: () => void;
};
const [ThemeProvider, useTheme] = createSafeContext<ThemeState>();
// ThemeButton.tsx
const ThemeButton = () => {
const toggleTheme = useTheme((ctx) => ctx.toggleTheme);
return <button onClick={toggleTheme}>Toggle Theme</button>;
};
// App.tsx
const App = () => {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const value = useMemo(() => ({
theme,
toggleTheme: () => setTheme((prev) => prev === 'light' ? 'dark' : 'light')
}), [theme]);
return (
<ThemeProvider value={value}>
<ThemeButton />
</ThemeProvider>
);
};
Key Advantages:
- Components only re-render when selected values change
- Full TypeScript type safety
- Eliminates context provider nesting
Bonus: Async State Management with Suspense
The Hack: Use experimental Suspense for data fetching
type Resource<T> = {
read: () => T;
};
const createResource = <T extends unknown>(promise: Promise<T>): Resource<T> => {
let status: 'pending' | 'success' | 'error' = 'pending';
let result: T | Error;
const suspender = promise.then(
(res) => {
status = 'success';
result = res;
},
(err) => {
status = 'error';
result = err;
}
);
return {
read() {
if (status === 'pending') throw suspender;
if (status === 'error') throw result;
return result as T;
}
};
};
// Usage
const UserProfile = ({ userId }: { userId: string }) => {
const userResource = useMemo(() =>
createResource(fetchUser(userId)),
[userId]);
const user = userResource.read();
return <div>{user.name}</div>;
};
State Management Checklist
- Profile First: Use React DevTools to identify unnecessary re-renders
-
Type Everything: Leverage TypeScript’s
strict
mode -
Layer Your State:
- Local → Component State
- Shared → URL/Atomic State
- Global → Observable Stores
- Test State Transitions: Use Jest/Testing Library for state machines
- Cache Strategically: Implement SWR/React Query for async data
FAQ
Q: When should I use these instead of Redux?
A: When you need lightweight solutions without middleware complexity
Q: Are observable patterns safe?
A: Yes, if you properly clean up subscriptions in useEffect
Q: How to handle server-state vs client-state?
A: Use React Query for server-state, these patterns for client-state
By mastering these patterns, you’ll write React code that’s more maintainable, performant, and type-safe. Remember: The best state management is the one that disappears into the background while your app logic shines through.
Top comments (0)