How I Removed Redux Toolkit From My React Native App


I was building a budget tracking app for couples when I noticed something: the code to manage state was overwhelming.

Let me show you what I mean.



The Redux Toolkit Tax

Here’s what my budget period management looked like with Redux Toolkit:

periodSlice.ts:

import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';

export const fetchPeriodSettings = createAsyncThunk(
  'period/fetchSettings',
  async (_, { rejectWithValue }) => {
    try {
      const response = await periodService.fetchPeriodSettings();
      return response;
    } catch (error) {
      return rejectWithValue(error.message);
    }
  }
);

export const fetchTransactions = createAsyncThunk(
  'period/fetchTransactions',
  async (_, { rejectWithValue }) => {
    try {
      const transactions = await transactionService.fetchTransactions();
      return transactions;
    } catch (error) {
      return rejectWithValue(error.message);
    }
  }
);

const periodSlice = createSlice({
  name: 'period',
  initialState: {
    settings: null,
    transactions: [],
    periodGroups: { groups: [] },
    activeGroupIndex: -1,
    isLoading: false,
    error: null,
  },
  reducers: {
    setActiveGroupIndex: (state, action) => {
      state.activeGroupIndex = action.payload;
    },
    nextGroup: (state) => {
      if (state.activeGroupIndex < state.periodGroups.groups.length - 1) {
        state.activeGroupIndex += 1;
      }
    },
    previousGroup: (state) => {
      if (state.activeGroupIndex > 0) {
        state.activeGroupIndex -= 1;
      }
    },
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchPeriodSettings.pending, (state) => {
        state.isLoading = true;
      })
      .addCase(fetchPeriodSettings.fulfilled, (state, action) => {
        state.settings = action.payload;
        state.isLoading = false;
      })
      .addCase(fetchPeriodSettings.rejected, (state, action) => {
        state.error = action.payload;
        state.isLoading = false;
      })
      // ... more cases for fetchTransactions
  },
});

export const { setActiveGroupIndex, nextGroup, previousGroup } = periodSlice.actions;
export default periodSlice.reducer;
Enter fullscreen mode

Exit fullscreen mode

store.ts:

import { configureStore } from '@reduxjs/toolkit';
import periodReducer from './slices/periodSlice';
import transactionReducer from './slices/transactionSlice';
import sessionReducer from './slices/sessionSlice';

export const store = configureStore({
  reducer: {
    period: periodReducer,
    transaction: transactionReducer,
    session: sessionReducer,
  },
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
Enter fullscreen mode

Exit fullscreen mode

hooks.ts:

import { useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from './store';

export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();
Enter fullscreen mode

Exit fullscreen mode

That’s three files and 150+ lines just for one slice of state. And I still needed to:

  • Set up selectors
  • Handle async loading states manually
  • Connect services to actions
  • Export and import types everywhere

Redux Toolkit is better than vanilla Redux. But it still felt like I was writing infrastructure instead of features.



What I Actually Needed

Here’s the same period management logic:

PeriodStore.ts (everything in one place):

import { Store } from 'nucleux';

class PeriodStore extends Store {
  // State atoms
  public periodSettings = this.atom<PeriodSetting | null>(null);
  public periodGroups = this.atom<GroupingResult>({ groups: [] });
  public activeGroupIndex = this.atom(-1);
  public isLoading = this.atom(false);

  // Derived state
  public activeGroup = this.deriveAtom(
    [this.periodGroups, this.activeGroupIndex],
    (periodGroups, index) => periodGroups.groups[index] ?? null
  );

  // Dependencies
  private periodService = this.inject(PeriodService);
  private transactionStore = this.inject(TransactionStore);

  constructor() {
    super();
    this.loadPeriodSettings(true);
  }

  private async loadPeriodSettings() {
    this.isLoading.value = true;

    const settings = await this.periodService.fetchPeriodSettings();
    this.periodSettings.value = settings;

    if (settings) {
      await this.transactionStore.fetchTransactions();
    }

    this.isLoading.value = false;
  }

  private onTransactionsChange(transactions: Transaction[]) {
    // Logic to handle transactions change
  }

  public nextGroup() {
    const maxIndex = this.periodGroups.value.groups.length - 1;
    if (this.activeGroupIndex.value < maxIndex) {
      this.activeGroupIndex.value += 1;
    }
  }

  public previousGroup() {
    if (this.activeGroupIndex.value > 0) {
      this.activeGroupIndex.value -= 1;
    }
  }
}

export default PeriodStore;
Enter fullscreen mode

Exit fullscreen mode

One file. One class. All the logic in one place. No actions, no reducers, no thunks, no extraReducers, no builder pattern.



Using It In Components

Before (Redux Toolkit):

import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { fetchPeriodSettings, nextGroup, previousGroup } from '@/store/slices/periodSlice';

function BudgetScreen() {
  const dispatch = useAppDispatch();
  const activeGroup = useAppSelector((state) => state.period.periodGroups.groups[state.period.activeGroupIndex]);
  const isLoading = useAppSelector((state) => state.period.isLoading);

  useEffect(() => {
    dispatch(fetchPeriodSettings());
  }, [dispatch]);

  const handleNext = () => dispatch(nextGroup());
  const handlePrevious = () => dispatch(previousGroup());

  return (
    <View>
      {isLoading ? <Loader /> : <PeriodView group={activeGroup} />}
      <Button onPress={handlePrevious} title="Previous" />
      <Button onPress={handleNext} title="Next" />
    View>
  );
}
Enter fullscreen mode

Exit fullscreen mode

After:

import { useStore, useValue } from 'nucleux';
import PeriodStore from '@/stores/PeriodStore';

function BudgetScreen() {
  const periodStore = useStore(PeriodStore);
  const activeGroup = useValue(periodStore.activeGroup);
  const isLoading = useValue(periodStore.isLoading);

  return (
    <View>
      {isLoading ? <Loader /> : <PeriodView group={activeGroup} />}
      <Button onPress={periodStore.previousGroup} title="Previous" />
      <Button onPress={periodStore.nextGroup} title="Next" />
    View>
  );
}
Enter fullscreen mode

Exit fullscreen mode

No dispatch. No action creators. No selectors with arrow functions. Just call methods directly on the store.



The Re-Render Win

Here’s where it got interesting. With Redux Toolkit, when periodSettings changed, every component using useAppSelector would re-evaluate its selector function. Even components that didn’t care about period settings.

My settings screen subscribed to just the session:

function SettingsScreen() {
  const session = useValue(sessionStore.session);
  // Only re-renders when session changes, not when period settings change
}
Enter fullscreen mode

Exit fullscreen mode

My budget screen subscribed to period data:

function BudgetScreen() {
  const activeGroup = useValue(periodStore.activeGroup);
  // Only re-renders when activeGroup changes, not when session changes
}
Enter fullscreen mode

Exit fullscreen mode

Each component only re-renders when its specific atom changes. Not when the store changes. Not when the slice changes. Just the atom it cares about.



Dependency Injection Without Context

With Redux Toolkit, I had services living outside the store. To use them in thunks, I either imported them directly (tight coupling) or passed them as arguments (tedious).

Now stores can inject other stores and services:

class TransactionStore extends Store {
  // Inject the service this store needs
  private transactionService = this.inject(TransactionService);

  public transactions = this.atom<Transaction[]>([]);

  async fetchTransactions() {
    const data = await this.transactionService.fetchTransactions();
    // ...
    this.transactions.value = data;
  }
}

class PeriodStore extends Store {
  // Inject another store
  private transactionStore = this.inject(TransactionStore);

  constructor() {
    super();

    // React to changes in another store
    this.watchAtom(
      this.transactionStore.transactions,
      (transactions) => {
        // Recalculate periods when transactions change
        this.groupPeriods(transactions);
      }
    );
  }
}
Enter fullscreen mode

Exit fullscreen mode

The IoC container handles instantiation. Stores and services are singletons. Dependencies just work.



The Expo Router Reality

Here’s what my app/_layout.tsx actually looks like:

import { Stack } from "expo-router";
import SessionProvider from "@/providers/SessionProvider";

export default function RootLayout() {
  return (
    <SessionProvider>
      <Stack screenOptions={{ headerShown: false }}>
        <Stack.Screen name="(tabs)" />
        <Stack.Screen name="(auth)" />
        <Stack.Screen name="(screens)" />
      Stack>
    SessionProvider>
  );
}
Enter fullscreen mode

Exit fullscreen mode

I still have one provider: SessionProvider. Not for state management—for Expo Router’s splash screen and navigation control:

export default function SessionProvider({ children }: SessionProviderProps) {
  const { initialized, session } = useNucleux(SessionStore);

  useEffect(() => {
    if (!initialized) return;

    // Route based on session state
    if (session) {
      router.replace("/(tabs)");
    } else {
      router.replace("/(auth)");
    }
  }, [session, initialized]);

  const onLayoutRootView = useCallback(async () => {
    if (initialized) {
      await SplashScreen.hideAsync();
    }
  }, [initialized]);

  if (!initialized) {
    return null;
  }

  return (
    <SessionContext.Provider value={{ session, onLayoutRootView }}>
      {children}
    SessionContext.Provider>
  );
}
Enter fullscreen mode

Exit fullscreen mode

This provider doesn’t manage state. It reads state from SessionStore and controls navigation. The state lives in Nucleux, not Context.

Modern Expo apps often end up with providers for:

  • Session management (auth routing)
  • Theme/appearance
  • Localization
  • Safe area context (from React Native)
  • Navigation container

That’s 5+ providers in _layout.tsx before you add any state management. With atomic state in stores, you don’t add more providers for state.



What About Persistence?

Redux Toolkit needs redux-persist. That’s another library, more config, wrapper components, and migration logic.

With atomic state:

class UserPreferencesStore extends Store {
  theme = this.atom('light', { 
    persistence: { persistKey: 'theme' } 
  });

  currency = this.atom('USD', { 
    persistence: { 
      persistKey: 'currency',
      storage: AsyncStorage  // React Native's AsyncStorage
    } 
  });
}
Enter fullscreen mode

Exit fullscreen mode

It automatically persists and rehydrates. One line per atom.



The Migration Path

I didn’t rewrite everything at once. Here’s how I did it:

Week 1: Created SessionStore for auth. Left Redux for everything else.

Week 2: Created TransactionStore. Transactions now lived in atomic state, periods still in Redux.

Week 3: Created PeriodStore. Now PeriodStore could inject TransactionStore and watch for changes.

Week 4: Deleted Redux entirely.

Each store was self-contained. The migration was:

  1. Create the store class
  2. Move logic from slice into store methods
  3. Replace useAppSelector with useValue
  4. Replace dispatch(action()) with store.method()
  5. Delete the slice file

The hardest part was deleting 500 lines of working Redux code.



What I Learned

Redux Toolkit made Redux better. But it still carries the original Redux architecture:

  • Actions as the API (you call action creators, not methods)
  • Reducers for updates (even with Immer, you’re writing reducers)
  • Separate selector layer (useAppSelector everywhere)
  • External async handling (thunks)
  • Manual typing (type RootState, type AppDispatch)

The atomic pattern flips this:

  • Stores as the API (you call methods directly)
  • Atoms for updates (just set .value)
  • No selector layer (subscribe to atoms directly)
  • Internal async handling (methods are just async)
  • Inferred typing (TypeScript figures it out)



The Results

Before:

  • 5 providers in _layout.tsx (Session, Redux, Theme, Safe Area, Stack)
  • ~1200 lines across slice files, store config, and hooks
  • Manual selector optimization with reselect
  • 8-12 files per feature (slice, selectors, thunks, types, hooks)

After:

  • 1 provider in _layout.tsx (just SessionProvider for routing)
  • ~600 lines of store code (just the stores)
  • Automatic atomic updates
  • 3-4 files per feature (store, service, types, components)

Developer Experience: New features went from taking 2-3 days to taking hours. No more “which file does this go in?” Everything for a feature lives in its store.



Why This Worked For Me

I’m not saying Redux Toolkit is wrong for everyone. But if you’re:

  • Building an Expo/React Native app
  • Managing complex, interconnected state
  • Writing more Redux boilerplate than business logic
  • Wanting built-in persistence
  • Looking for better TypeScript inference

You might want to try atomic state management.

I built this pattern into a library called Nucleux because I kept using it across projects. But the concepts work without the library:

  • Atoms over global state trees
  • Classes over actions/reducers
  • Dependency injection over imports
  • Direct method calls over dispatch

The biggest win wasn’t code reduction—it was clarity. My budget feature’s logic lives in PeriodStore. My transaction logic lives in TransactionStore. When stores need to communicate, they inject each other. No indirection, no dispatch, no selectors.




Try It Yourself

Want to see if this works for your app? Start small:

  1. Pick one Redux slice (sessions work well)
  2. Convert it to a store class
  3. Compare the code

If you like it, keep going. If not, they can coexist during evaluation.

The repo with examples: github.com/martyroque/nucleux

Demo sandbox: codesandbox.io/p/sandbox/nucleux-react-qw58s4

Would love to hear your thoughts—especially if you’ve hit similar challenges with Redux Toolkit.


Building Duetto – a privacy-first budget app for couples. The entire app uses atomic state management, and it’s changed how fast I can ship features.



Source link

Leave a Reply

Your email address will not be published. Required fields are marked *