Miért változott meg minden az állapotkezelésben 2026-ra?
Valószínűleg te is ismered azt a pillanatot, amikor egy ártalmatlan kis felhasználói beállítás megváltoztatása az egész alkalmazásodat újrarendereli. Vagy amikor a Redux boilerplate kód már több helyet foglal el, mint maga az üzleti logika. Nálam ez volt az a pont, amikor elkezdtem alternatívák után kutatni – és 2026-ban szerencsére nem kellett sokáig keresgélnem.
A válasz: Zustand plusz TanStack Query.
Az elmúlt évben a React Native ökoszisztéma egyértelműen elkötelezte magát a kliens állapot és a szerver állapot szétválasztása mellett. Ez nem csupán egy újabb hype – valódi paradigmaváltás, ami alapjaiban változtatja meg, hogyan gondolkodunk az adatáramlásról mobilalkalmazásokban. A projektek mintegy 40%-a már Zustand-ot használ, a TanStack Query pedig heti 5 millió letöltéssel a szerver oldali állapotkezelés de facto standardjává vált.
Ebben az útmutatóban lépésről lépésre megmutatom, hogyan építs fel egy modern állapotkezelési architektúrát React Native-ban a Zustand v5 és a TanStack Query v5 együttes használatával. Mindent a nulláról, működő kódpéldákkal – szóval gyerünk is bele.
Kliens állapot vs. szerver állapot – miért kell szétválasztani?
Ez az egész 2026-os állapotkezelési megközelítés legfontosabb alapelve, úgyhogy érdemes tisztázni, miről is beszélünk.
Mi a kliens állapot?
A kliens állapot az alkalmazásodban lokálisan létező adatokat jelenti, amelyeknek nincs közvetlen kapcsolatuk egy távoli szerverrel. Tipikus példák:
- UI állapot (modálok nyitva/zárva, sötét téma bekapcsolva, sidebar pozíciója)
- Felhasználói preferenciák (nyelv, betűméret, értesítési beállítások)
- Űrlap állapotok (kitöltött mezők, validációs hibák)
- Navigációs állapot (aktuális tab, visszajelzések)
Mi a szerver állapot?
A szerver állapot ezzel szemben olyan adat, ami egy távoli szerveren él, és a kliens csak egy másolatát – lényegében a cache-ét – tartja meg:
- API-ból lekért felhasználói profil adatok
- Terméklisták, keresési eredmények
- Értesítések, üzenetek
- Bármilyen adat, amit más felhasználók is módosíthatnak
Miért probléma, ha összekeverjük őket?
A hagyományos Redux-alapú megközelítésben mindent egyetlen globális store-ban tartottunk. Ez eleinte tűrhetően működött, de ahogy az alkalmazás nőtt, a gondok is jöttek: a szerver adatok gyorsítótárazását, frissítését, invalidálását és az optimista frissítéseket mind kézzel kellett megoldani. Ráadásul a Redux store-ban tárolt szerver adat gyakorlatilag soha nem volt igazán „friss" – hacsak nem írtál rengeteg extra kódot a háttérben történő újralekéréshez (és őszintén, ki szeret ilyesmivel szórakozni?).
A modern megközelítés ennél sokkal egyszerűbb: a Zustand kezeli a kliens állapotot, a TanStack Query pedig a szerver állapotot. Mindkettő azt csinálja, amiben a legjobb, és nem próbálja átvenni a másik dolgát.
A Zustand beállítása React Native-ban
Telepítés
A Zustand v5 telepítése brutálisan egyszerű. Expo projektben ennyi az egész:
npx expo install zustand @react-native-async-storage/async-storage
Az AsyncStorage-ot azért telepítjük rögtön, mert a persist middleware-hez kell majd az offline adatmegőrzéshez.
Az első store létrehozása
A Zustand legnagyobb előnye a Redux-szal szemben? Szó szerint pár sor kódból áll egy teljes értékű store. Komolyan:
// stores/useAppStore.ts
import { create } from 'zustand';
interface AppState {
isDarkMode: boolean;
language: string;
toggleDarkMode: () => void;
setLanguage: (lang: string) => void;
}
export const useAppStore = create<AppState>((set) => ({
isDarkMode: false,
language: 'hu',
toggleDarkMode: () => set((state) => ({ isDarkMode: !state.isDarkMode })),
setLanguage: (lang) => set({ language: lang }),
}));
Ennyi. Nincs Provider, nincs reducer, nincs action creator, nincs dispatch. Létrehozol egy hook-ot, és kész – bárhol használhatod a komponenseidben. Amikor először láttam ezt Redux után, őszintén szólva kicsit gyanús volt, hogy tényleg ennyi elég.
Használat komponensekben
// components/ThemeToggle.tsx
import { useAppStore } from '../stores/useAppStore';
import { Switch, Text, View } from 'react-native';
export function ThemeToggle() {
const isDarkMode = useAppStore((state) => state.isDarkMode);
const toggleDarkMode = useAppStore((state) => state.toggleDarkMode);
return (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Text>{isDarkMode ? 'Sötét mód' : 'Világos mód'}</Text>
<Switch value={isDarkMode} onValueChange={toggleDarkMode} />
</View>
);
}
Figyeld meg a szelektort: useAppStore((state) => state.isDarkMode). Ez nem csak szintaktikai cukorka – ez a Zustand teljesítményoptimalizálásának lényege. A komponens csak akkor renderelődik újra, ha pontosan ez az érték változik, nem pedig az egész store minden módosításakor. Ez egy apró részlet, de hatalmas különbséget jelent nagyobb alkalmazásoknál.
Offline perzisztencia a persist middleware-rel
Mobilalkalmazásoknál kritikus, hogy a felhasználói beállítások megmaradjanak az app újraindítása után is. Senki nem akarja minden egyes megnyitásnál újra beállítani a sötét módot. A Zustand beépített persist middleware-je ezt gyerekjátékká teszi:
// stores/useSettingsStore.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
interface SettingsState {
isDarkMode: boolean;
fontSize: number;
notificationsEnabled: boolean;
toggleDarkMode: () => void;
setFontSize: (size: number) => void;
toggleNotifications: () => void;
}
export const useSettingsStore = create<SettingsState>()(
persist(
(set) => ({
isDarkMode: false,
fontSize: 16,
notificationsEnabled: true,
toggleDarkMode: () => set((state) => ({ isDarkMode: !state.isDarkMode })),
setFontSize: (size) => set({ fontSize: size }),
toggleNotifications: () =>
set((state) => ({ notificationsEnabled: !state.notificationsEnabled })),
}),
{
name: 'settings-storage',
storage: createJSONStorage(() => AsyncStorage),
}
)
);
Ezzel a megoldással a store tartalma automatikusan mentődik az AsyncStorage-ba, és az app újraindításakor visszatöltődik. Egy fontos kitétel: érzékeny adatokat (tokenek, jelszavak) ne tárolj az AsyncStorage-ban – arra használd az expo-secure-store csomagot.
A TanStack Query integrálása React Native-ban
Telepítés és alapkonfiguráció
npx expo install @tanstack/react-query
Hozd létre a QueryClient konfigurációt, aztán csomagold be az alkalmazásodat a QueryClientProvider-rel:
// utils/queryClient.ts
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 perc
gcTime: 1000 * 60 * 30, // 30 perc (korábban cacheTime)
retry: 2,
refetchOnWindowFocus: true,
},
},
});
// app/_layout.tsx
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from '../utils/queryClient';
import { Stack } from 'expo-router';
export default function RootLayout() {
return (
<QueryClientProvider client={queryClient}>
<Stack />
</QueryClientProvider>
);
}
React Native specifikus beállítások
Na, itt jön az a rész, amit sokan kihagynak, aztán csodálkoznak, hogy miért nem frissülnek az adatok, amikor visszaváltanak az appra. Mobilalkalmazásoknál meg kell mondanod a TanStack Query-nek, mikor kerül az app előtérbe, hogy automatikusan frissítse az adatokat:
// hooks/useAppStateRefresh.ts
import { useEffect } from 'react';
import { AppState, Platform } from 'react-native';
import type { AppStateStatus } from 'react-native';
import { focusManager } from '@tanstack/react-query';
function onAppStateChange(status: AppStateStatus) {
if (Platform.OS !== 'web') {
focusManager.setFocused(status === 'active');
}
}
export function useAppStateRefresh() {
useEffect(() => {
const subscription = AppState.addEventListener('change', onAppStateChange);
return () => subscription.remove();
}, []);
}
// hooks/useOnlineManager.ts
import { useEffect } from 'react';
import NetInfo from '@react-native-community/netinfo';
import { onlineManager } from '@tanstack/react-query';
export function useOnlineManager() {
useEffect(() => {
return NetInfo.addEventListener((state) => {
const status = !!state.isConnected;
onlineManager.setOnline(status);
});
}, []);
}
Ezeket a hook-okat a gyökér layout-ban hívd meg, hogy az egész alkalmazásra érvényesek legyenek:
// app/_layout.tsx (frissített verzió)
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from '../utils/queryClient';
import { useAppStateRefresh } from '../hooks/useAppStateRefresh';
import { useOnlineManager } from '../hooks/useOnlineManager';
import { Stack } from 'expo-router';
export default function RootLayout() {
useAppStateRefresh();
useOnlineManager();
return (
<QueryClientProvider client={queryClient}>
<Stack />
</QueryClientProvider>
);
}
Query hook-ok létrehozása
Most jön a lényeg – a szerver állapot kezelése. Hozzunk létre egy API réteget és a hozzá tartozó query hook-okat:
// api/products.ts
const API_BASE = 'https://api.example.com';
export interface Product {
id: string;
name: string;
price: number;
category: string;
imageUrl: string;
}
export async function fetchProducts(category?: string): Promise<Product[]> {
const url = category
? `${API_BASE}/products?category=${category}`
: `${API_BASE}/products`;
const response = await fetch(url);
if (!response.ok) {
throw new Error('Nem sikerült betölteni a termékeket');
}
return response.json();
}
export async function fetchProductById(id: string): Promise<Product> {
const response = await fetch(`${API_BASE}/products/${id}`);
if (!response.ok) {
throw new Error('A termék nem található');
}
return response.json();
}
// hooks/queries/useProducts.ts
import { useQuery } from '@tanstack/react-query';
import { fetchProducts, fetchProductById } from '../../api/products';
// Query key factory – konzisztens kulcskezelés
export const productKeys = {
all: ['products'] as const,
lists: () => [...productKeys.all, 'list'] as const,
list: (category?: string) => [...productKeys.lists(), { category }] as const,
details: () => [...productKeys.all, 'detail'] as const,
detail: (id: string) => [...productKeys.details(), id] as const,
};
export function useProducts(category?: string) {
return useQuery({
queryKey: productKeys.list(category),
queryFn: () => fetchProducts(category),
});
}
export function useProduct(id: string) {
return useQuery({
queryKey: productKeys.detail(id),
queryFn: () => fetchProductById(id),
enabled: !!id,
});
}
A query key factory minta (amit fentebb látsz a productKeys objektummal) megéri a befektetett energiát – konzisztenssé teszi a cache kulcsokat az egész alkalmazásban, és a cache invalidálás is egyszerűbb lesz tőle.
Mutation-ök és cache invalidáció
Az adatlekérés persze csak a fele a képnek. A szerver adatok módosítása (létrehozás, frissítés, törlés) a mutation-ökön keresztül történik:
// hooks/mutations/useCreateProduct.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { productKeys } from '../queries/useProducts';
interface CreateProductInput {
name: string;
price: number;
category: string;
}
async function createProduct(input: CreateProductInput) {
const response = await fetch('https://api.example.com/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (!response.ok) throw new Error('Nem sikerült létrehozni a terméket');
return response.json();
}
export function useCreateProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createProduct,
onSuccess: () => {
// A terméklista cache-ének automatikus invalidálása
queryClient.invalidateQueries({ queryKey: productKeys.lists() });
},
});
}
A Zustand és TanStack Query együttes használata a gyakorlatban
Rendben, eddig külön-külön néztük őket. De hogyan működik ez a kettő egymás mellett egy valós szituációban? Nézzünk egy terméklistázó képernyőt szűréssel – ez egy olyan use case, ami szinte minden appban előjön valamilyen formában:
// stores/useFilterStore.ts
import { create } from 'zustand';
interface FilterState {
selectedCategory: string | null;
sortBy: 'price' | 'name' | 'newest';
searchQuery: string;
setCategory: (category: string | null) => void;
setSortBy: (sort: 'price' | 'name' | 'newest') => void;
setSearchQuery: (query: string) => void;
resetFilters: () => void;
}
export const useFilterStore = create<FilterState>((set) => ({
selectedCategory: null,
sortBy: 'newest',
searchQuery: '',
setCategory: (category) => set({ selectedCategory: category }),
setSortBy: (sort) => set({ sortBy: sort }),
setSearchQuery: (query) => set({ searchQuery: query }),
resetFilters: () =>
set({ selectedCategory: null, sortBy: 'newest', searchQuery: '' }),
}));
// screens/ProductListScreen.tsx
import { View, FlatList, Text, ActivityIndicator, TextInput } from 'react-native';
import { useFilterStore } from '../stores/useFilterStore';
import { useProducts } from '../hooks/queries/useProducts';
import { ProductCard } from '../components/ProductCard';
import { CategoryFilter } from '../components/CategoryFilter';
import { useMemo } from 'react';
export function ProductListScreen() {
// Kliens állapot – Zustand
const selectedCategory = useFilterStore((s) => s.selectedCategory);
const sortBy = useFilterStore((s) => s.sortBy);
const searchQuery = useFilterStore((s) => s.searchQuery);
const setSearchQuery = useFilterStore((s) => s.setSearchQuery);
// Szerver állapot – TanStack Query
const { data: products, isLoading, error, refetch } = useProducts(
selectedCategory ?? undefined
);
// Kliens oldali szűrés és rendezés (a lekért adatokon)
const filteredProducts = useMemo(() => {
if (!products) return [];
let result = products.filter((p) =>
p.name.toLowerCase().includes(searchQuery.toLowerCase())
);
switch (sortBy) {
case 'price':
result.sort((a, b) => a.price - b.price);
break;
case 'name':
result.sort((a, b) => a.name.localeCompare(b.name, 'hu'));
break;
case 'newest':
default:
break;
}
return result;
}, [products, searchQuery, sortBy]);
if (isLoading) {
return <ActivityIndicator size="large" />;
}
if (error) {
return (
<View>
<Text>Hiba történt: {error.message}</Text>
</View>
);
}
return (
<View style={{ flex: 1 }}>
<TextInput
placeholder="Keresés..."
value={searchQuery}
onChangeText={setSearchQuery}
style={{ padding: 12, borderBottomWidth: 1, borderColor: '#ddd' }}
/>
<CategoryFilter />
<FlatList
data={filteredProducts}
renderItem={({ item }) => <ProductCard product={item} />}
keyExtractor={(item) => item.id}
onRefresh={refetch}
refreshing={isLoading}
/>
</View>
);
}
Figyeld meg, milyen szépen elkülönülnek a felelősségek:
- A Zustand kezeli a szűrőket, rendezést és keresési szöveget – ezek tisztán kliens oldali állapotok, nincs közük a szerverhez
- A TanStack Query kezeli a termékek lekérését az API-ból, a gyorsítótárazást és a háttérfrissítést
- A
useMemoa kliens oldali szűrést végzi a már lekért adatokon – nem kell minden billentyűleütésnél új API hívást indítani
Ez a szétválasztás első ránézésre talán apróságnak tűnik, de hidd el, egy nagyobb projektnél ez az, ami megkülönbözteti a karbantartható kódot a „senki nem akar hozzányúlni" kategóriától.
Optimista frissítések megvalósítása
Az optimista frissítés az a technika, amikor a UI-t azonnal frissítjük, mielőtt a szerver visszajelezne. A felhasználónak így villámgyorsnak tűnik az app. Ha aztán a szerver hiba történik, automatikusan visszagörgetjük az előző állapotot – mintha mi sem történt volna:
// hooks/mutations/useToggleFavorite.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { productKeys } from '../queries/useProducts';
import type { Product } from '../../api/products';
export function useToggleFavorite() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (productId: string) => {
const response = await fetch(
`https://api.example.com/products/${productId}/favorite`,
{ method: 'POST' }
);
if (!response.ok) throw new Error('Nem sikerült');
return response.json();
},
// Optimista frissítés: azonnal frissítjük a cache-t
onMutate: async (productId) => {
// Folyamatban lévő lekérések leállítása
await queryClient.cancelQueries({ queryKey: productKeys.all });
// Előző állapot mentése (rollback-hez)
const previousProduct = queryClient.getQueryData<Product>(
productKeys.detail(productId)
);
// Cache optimista frissítése
if (previousProduct) {
queryClient.setQueryData(productKeys.detail(productId), {
...previousProduct,
isFavorite: !previousProduct.isFavorite,
});
}
return { previousProduct };
},
// Hiba esetén visszaállítás
onError: (_err, productId, context) => {
if (context?.previousProduct) {
queryClient.setQueryData(
productKeys.detail(productId),
context.previousProduct
);
}
},
// Siker vagy hiba után mindenképpen frissítjük a szerverről
onSettled: () => {
queryClient.invalidateQueries({ queryKey: productKeys.all });
},
});
}
Ez elsőre talán sok kódnak tűnik egyetlen „kedvenc" gombhoz, de a felhasználói élménybeli különbség óriási. A gomb azonnal reagál, és ha valami félremegy, a rollback automatikus.
Ajánlott projektstruktúra
Miután jó pár projektet felépítettem ezzel a stack-kel, a következő mappastruktúra vált be a legjobban:
src/
├── app/ # Expo Router útvonalak
│ ├── _layout.tsx # Gyökér layout (QueryClientProvider)
│ ├── index.tsx
│ └── product/
│ └── [id].tsx
├── api/ # API függvények (fetch hívások)
│ ├── products.ts
│ └── users.ts
├── hooks/
│ ├── queries/ # TanStack Query hook-ok
│ │ ├── useProducts.ts
│ │ └── useUsers.ts
│ ├── mutations/ # Mutation hook-ok
│ │ ├── useCreateProduct.ts
│ │ └── useToggleFavorite.ts
│ ├── useAppStateRefresh.ts
│ └── useOnlineManager.ts
├── stores/ # Zustand store-ok
│ ├── useAppStore.ts
│ ├── useFilterStore.ts
│ └── useSettingsStore.ts
├── components/ # UI komponensek
│ ├── ProductCard.tsx
│ └── CategoryFilter.tsx
└── utils/
└── queryClient.ts # QueryClient konfiguráció
Az alapelv egyszerű: az api/ mappába kerülnek a nyers fetch hívások, a hooks/queries/ és hooks/mutations/ mappákba a TanStack Query hook-ok, a stores/ mappába pedig a Zustand store-ok. Minden szépen el van különítve, könnyen tesztelhető és karbantartható. Nem rocket science, de megéri betartani.
Teljesítmény-összehasonlítás: miért éri meg váltani?
Oké, eddig sok szép mintát láttunk, de nézzük a konkrét számokat is – mert azok szoktak meggyőzni, ha egy tech lead-et kell rávenni a váltásra:
- Bundle méret: A Zustand (~1 KB) + TanStack Query (~12 KB) együtt is kisebb, mint a Redux Toolkit önmagában (~15 KB), és ez nem számítja bele a react-redux-ot sem
- Boilerplate csökkenés: A Zustand nagyjából 65%-kal kevesebb kódot igényel, mint a Redux Toolkit hasonló funkciókészlethez
- Újrarenderelés optimalizálás: A Zustand szelektor-alapú megközelítése garantálja, hogy egy komponens csak akkor renderelődik újra, amikor az általa használt konkrét érték változik
- Automatikus cache kezelés: A TanStack Query stale-while-revalidate stratégiája azt jelenti, hogy a felhasználó azonnal látja a cache-elt adatot, miközben a háttérben frissül – ezt Reduxban kézzel kellene implementálni
- Offline támogatás: A Zustand persist middleware + TanStack Query offline kezelése minimális konfigurációval működik
Gyakran ismételt kérdések
Kell-e még Redux, ha Zustand-ot és TanStack Query-t használok?
A legtöbb projektnél őszintén nem. A Zustand + TanStack Query kombináció lefedi a kliens és szerver állapotkezelés teljes spektrumát. A Redux Toolkit-et akkor érdemes továbbra is használni, ha nagyon nagy, vállalati szintű alkalmazásról van szó több fejlesztőcsapattal, ahol a szigorú egyirányú adatáramlás és a fejlett DevTools eszközök kiemelt fontosságúak. A projektek túlnyomó többségénél viszont a Zustand + TanStack Query elegendő és lényegesen egyszerűbb.
Hogyan kezeli a Zustand az újrarenderelést React Native-ban?
A Zustand a React 18+ beépített useSyncExternalStore hook-ját használja (a v5 óta), ami natív szintű integrációt biztosít a React renderelési ciklusával. A szelektorok gondoskodnak arról, hogy egy komponens csak akkor renderelődjön újra, ha az általa figyelt érték ténylegesen változik. Ez drámaian csökkenti a felesleges újrarendereléseket a Context API-hoz képest, ahol bármilyen változás az összes fogyasztó komponens újrarenderelését kiváltja.
Használhatom a TanStack Query-t Expo Router-rel?
Igen, tökéletesen kompatibilisek. A QueryClientProvider-t a gyökér _layout.tsx fájlban kell elhelyezned, és az összes route automatikusan hozzáfér a query klienshez. Az Expo Router fájl-alapú routing rendszere és a TanStack Query hook-ok remekül kiegészítik egymást: minden route-nak saját query hook-jai lehetnek, amelyek automatikusan kezelik az adatlekérést, gyorsítótárazást és frissítést.
Mi a legjobb módja az offline adatkezelésnek React Native-ban?
A leghatékonyabb megközelítés a Zustand persist middleware használata AsyncStorage-dzsal a kliens állapothoz (beállítások, preferenciák), és a TanStack Query beépített offline támogatásának kihasználása a szerver adatokhoz. A TanStack Query automatikusan tárolja a lekért adatokat a memóriában, és offline módban a legutóbb gyorsítótárazott adatot jeleníti meg. Ha tartós offline tárolásra van szükséged a szerver adatokhoz is, nézd meg a @tanstack/query-async-storage-persister csomagot.
Érdemes Jotai-t használni a Zustand helyett React Native-ban?
Mindkettő kiváló választás, de más a filozófiájuk. A Zustand egy központi store-alapú megoldás, ami ismerős lesz a Redux-hoz szokott fejlesztőknek – globális store-okat hozol létre és szelektorokkal éred el az adatokat. A Jotai ezzel szemben atomikus megközelítést használ (alulról felfelé építkezik), ami akkor ideális, ha sok kis, független állapotdarabod van. Tapasztalatom szerint React Native-ban a Zustand általában egyszerűbb belépési pontot kínál és jobban skálázódik közepes méretű alkalmazásoknál, míg a Jotai összetett, egymásból származtatott állapotoknál remekel.