DEV Community

SOVANNARO
SOVANNARO

Posted on

1

5 State Management Hacks in React You’ve Never Tried (But Should)

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' });
};
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>;
};
Enter fullscreen mode Exit fullscreen mode

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);
    }
  };
};
Enter fullscreen mode Exit fullscreen mode

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 });
    }
  };
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>;
};
Enter fullscreen mode Exit fullscreen mode

State Management Checklist

  1. Profile First: Use React DevTools to identify unnecessary re-renders
  2. Type Everything: Leverage TypeScript’s strict mode
  3. Layer Your State:
    • Local → Component State
    • Shared → URL/Atomic State
    • Global → Observable Stores
  4. Test State Transitions: Use Jest/Testing Library for state machines
  5. 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.

Image of Timescale

🚀 pgai Vectorizer: SQLAlchemy and LiteLLM Make Vector Search Simple

We built pgai Vectorizer to simplify embedding management for AI applications—without needing a separate database or complex infrastructure. Since launch, developers have created over 3,000 vectorizers on Timescale Cloud, with many more self-hosted.

Read more

Top comments (0)

A Workflow Copilot. Tailored to You.

Pieces.app image

Our desktop app, with its intelligent copilot, streamlines coding by generating snippets, extracting code from screenshots, and accelerating problem-solving.

Read the docs