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;
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;
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>();
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;
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>
);
}
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>
);
}
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
}
My budget screen subscribed to period data:
function BudgetScreen() {
const activeGroup = useValue(periodStore.activeGroup);
// Only re-renders when activeGroup changes, not when session changes
}
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);
}
);
}
}
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>
);
}
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>
);
}
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
}
});
}
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:
- Create the store class
- Move logic from slice into store methods
- Replace
useAppSelector
withuseValue
- Replace
dispatch(action())
withstore.method()
- 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:
- Pick one Redux slice (sessions work well)
- Convert it to a store class
- 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.