Zustand és TanStack Query React Native-ban: gyakorlati útmutató

Hogyan építs modern állapotkezelési architektúrát React Native-ban Zustand v5 és TanStack Query v5 használatával? Kliens és szerver állapot szétválasztása, offline perzisztencia, optimista frissítések – működő kódpéldákkal.

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 useMemo a 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.

A Szerzőről Editorial Team

Our team of expert writers and editors.