Upravljanje stanjem u Reactu često može učiniti stvari kompliciranijima nego što trebaju biti. Redux zahtijeva puno boilerplatea. Context API uzrokuje probleme s ponovnim renderiranjem. MobX oduzima puno vremena za učenje. Valtio radi stvari drugačije: koristi direktne mutacije s automatskom reaktivnošću.
Ovaj vodič vam govori sve što trebate znati za početak korištenja Valtia u stvarnim aplikacijama.
Što je Valtio?
Daishi Kato, koji je također napravio Zustand i Jotai, napravio je Valtio, biblioteku za upravljanje stanjem temeljenu na proxyjima od samo 3KB. Automatski prati promjene stanja koristeći JavaScript Proxy objekte.
Glavna ideja je jednostavna:
import { proxy, useSnapshot } from 'valtio';
// Učini stanje reaktivnim
const state = proxy({ count: 0, user: null });
// Mijenjaj direktno—bez akcija, bez dispatcha
state.count++;
state.user = { name: 'John' };
Bez reducera. Bez action creatora. Bez dispatch funkcija. Mijenjate stanje direktno, a Valtio se brine za sve ostalo.
Instalacija
npm install valtio
# ili
yarn add valtio
Dolazi s TypeScript tipovima. Radi s Reactom 16.8 i novijim, Next.js-om i React Nativeom.
Dva osnovna API-ja: proxy i useSnapshot
Postoje dvije glavne stvari koje Valtio radi. Ako naučite ove, moći ćete riješiti 90% situacija.
proxy – Napravi reaktivno stanje
Funkcija proxy omata vaš state objekt:
import { proxy } from 'valtio';
interface AppState {
user: User | null;
theme: 'light' | 'dark';
notifications: Notification[];
}
export const appState = proxy<AppState>({
user: null,
theme: 'light',
notifications: [],
});
Korištenje TypeScript interfejsa omogućuje autocomplete i pronalazak grešaka prije pokretanja koda.
useSnapshot – Čitaj stanje u komponentama
useSnapshot omogućuje komponentama pristup stanju. Ovaj hook čini da komponenta sluša promjene:
import { useSnapshot } from 'valtio';
import { appState } from './store';
function Header() {
const snap = useSnapshot(appState);
return (
<header>
<span>Bok, {snap.user?.name}</span>
<span>{snap.notifications.length} novih obavijesti</span>
</header>
);
}
Snapshot ne možete mijenjati. Promjene se događaju na proxyju, ne na snapshotu.
Mijenjanje stanja
Mijenjajte proxy objekt direktno s bilo kojeg mjesta:
// U event handlerima
function handleLogin(user: User) {
appState.user = user;
}
// U async funkcijama
async function fetchNotifications() {
const data = await api.getNotifications();
appState.notifications = data;
}
// Toggle vrijednosti
function toggleTheme() {
appState.theme = appState.theme === 'light' ? 'dark' : 'light';
}
Struktura projekta
Za održavanje čiste kodne baze, odvojite storeove od akcija:
src/valtio/
├── users/
│ ├── users.store.ts # Definicija stanja
│ └── users.actions.ts # Funkcije za mutacije
├── auth/
│ ├── auth.store.ts
│ └── auth.actions.ts
└── global/
├── global.store.ts
└── global.actions.ts
Ovaj pattern dobro funkcionira s većim projektima i drži povezani kod zajedno.
Potpuni primjer: Users Store
Ovo je store pattern spreman za produkciju:
// src/valtio/users/users.store.ts
import { proxy, useSnapshot } from 'valtio';
interface UsersStore {
users: User[];
selectedUser?: User;
isLoading: boolean;
error?: string;
modalOpen: boolean;
}
export const usersStore = proxy<UsersStore>({
users: [],
selectedUser: undefined,
isLoading: false,
error: undefined,
modalOpen: false,
});
export const useUsersStore = () => useSnapshot(usersStore);
// src/valtio/users/users.actions.ts
import { UsersService } from '@/services';
import { usersStore } from './users.store';
export async function fetchUsers() {
usersStore.isLoading = true;
usersStore.error = undefined;
try {
usersStore.users = await UsersService.getAll();
} catch (e) {
usersStore.error = 'Nije moguće učitati korisnike';
} finally {
usersStore.isLoading = false;
}
}
export function selectUser(user: User) {
usersStore.selectedUser = user;
}
export function toggleModal() {
usersStore.modalOpen = !usersStore.modalOpen;
}
export function clearSelection() {
usersStore.selectedUser = undefined;
}
Kako koristiti u komponentama:
import { useEffect } from 'react';
import { fetchUsers, selectUser, toggleModal } from '@/valtio/users/users.actions';
import { useUsersStore } from '@/valtio/users/users.store';
function UsersPage() {
const { users, isLoading, error, modalOpen } = useUsersStore();
useEffect(() => {
fetchUsers();
}, []);
if (isLoading) return <Loading />;
if (error) return <Error message={error} />;
return (
<div>
<button onClick={toggleModal}>Dodaj korisnika</button>
{users.map(user => (
<UserCard key={user.id} user={user} onClick={() => selectUser(user)} />
))}
{modalOpen && <UserModal />}
</div>
);
}
Vidite kako se akcije pozivaju direktno bez korištenja hookova ili dispatcha? Ne trebate useSnapshot za komponente koje samo pokreću akcije.
Globalni Store: Toast obavijesti
Globalni store upravlja problemima koji utječu na cijelu aplikaciju:
// src/valtio/global/global.store.ts
import { proxy, useSnapshot } from 'valtio';
interface Toast {
id: string;
type: 'success' | 'error' | 'info';
message: string;
}
interface GlobalStore {
toasts: Toast[];
}
export const globalStore = proxy<GlobalStore>({
toasts: [],
});
export const useGlobalStore = () => useSnapshot(globalStore);
// src/valtio/global/global.actions.ts
import { globalStore } from './global.store';
export function showToast(type: Toast['type'], message: string) {
const id = crypto.randomUUID();
globalStore.toasts.push({ id, type, message });
// Automatski ukloni nakon 3 sekunde
setTimeout(() => {
const index = globalStore.toasts.findIndex(t => t.id === id);
if (index > -1) globalStore.toasts.splice(index, 1);
}, 3000);
}
export function dismissToast(id: string) {
const index = globalStore.toasts.findIndex(t => t.id === id);
if (index > -1) globalStore.toasts.splice(index, 1);
}
Ovo možete koristiti bilo gdje u aplikaciji:
import { showToast } from '@/valtio/global/global.actions';
// Nakon uspješnog spremanja
showToast('success', 'Promjene spremljene!');
// Nakon greške
showToast('error', 'Nešto je pošlo po zlu');
Ovaj pattern dobro funkcionira s Material-UI pri izradi UI komponenti.
Valtio vs Redux: Usporedba
Isti brojač u Redux Toolkitu:
// Redux: 25+ linija
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
increment: state => {
state.value++;
},
decrement: state => {
state.value--;
},
},
});
const store = configureStore({
reducer: { counter: counterSlice.reducer },
});
// Komponenta treba useSelector + useDispatch
const count = useSelector(state => state.counter.value);
const dispatch = useDispatch();
dispatch(increment());
Isti brojač u Valtiu:
// Valtio: 8 linija
const counter = proxy({ value: 0 });
const increment = () => counter.value++;
const decrement = () => counter.value--;
// Komponenta
const snap = useSnapshot(counter);
// snap.value, increment(), decrement()
Isti rezultat, manje koda.
Spremanje stanja u LocalStorage
import { proxy, subscribe } from 'valtio';
const STORAGE_KEY = 'app_settings';
// Učitaj iz storagea
const saved = localStorage.getItem(STORAGE_KEY);
const initial = saved ? JSON.parse(saved) : { theme: 'light', language: 'en' };
export const settingsStore = proxy(initial);
// Automatski spremi kad se dogode promjene
subscribe(settingsStore, () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settingsStore));
});
Ovaj pattern sprema korisničke preferencije između sesija za aplikacije koje podržavaju više jezika.
Korištenje Valtia s Next.js
Valtio radi s Next.js, ali SSR zahtijeva malo rada:
// app/users/page.tsx (Server Component)
import { UsersClient } from './UsersClient';
export default async function UsersPage() {
const users = await fetchUsers(); // Server fetch
return <UsersClient initialUsers={users} />;
}
// app/users/UsersClient.tsx
'use client'
import { useEffect } from 'react'
import { usersStore, useUsersStore } from '@/valtio/users/users.store'
export function UsersClient({ initialUsers }) {
const { users } = useUsersStore()
useEffect(() => {
usersStore.users = initialUsers // Hidriraj pri mountanju
}, [initialUsers])
return (
// Vaš JSX ovdje
)
}
Valtio i next-intl dobro rade zajedno za Next.js prijevode.
Česte greške
Mijenjanje snapshota
// Krivo: snapshot je read-only
const snap = useSnapshot(store);
snap.value = 'new'; // Greška!
// Ispravno: mijenjaj proxy
store.value = 'new';
Kreiranje proxyja unutar komponente
// Krivo—kreira novi proxy za svaki render
function Component() {
const state = proxy({ count: 0 }) // Nemoj!
}
// Ispravno—definiraj izvan
const state = proxy({ count: 0 })
function Component() {
const snap = useSnapshot(state)
}
Pristupanje proxyju direktno u JSX-u
// Krivo—neće se rerenderirati
return <div>{store.name}</div>
// Ispravno—koristi snapshot
const snap = useSnapshot(store)
return <div>{snap.name}</div>
Best practice
Pravila imenovanja
| Pattern | Stavka | Primjer |
|---|---|---|
| Store | {feature}Store | usersStore |
| Hook | use{Feature}Store | useUsersStore |
| Akcije | glagoli | fetchUsers, toggleModal |
Struktura storea
interface FeatureStore {
// Podaci
items: Item[];
selected?: Item;
// Loading/Error
isLoading: boolean;
error?: string;
// UI stanje
modalOpen: boolean;
}
Struktura datoteka
feature/
├── feature.store.ts # Samo stanje
└── feature.actions.ts # Sve mutacije
Kombiniranje Valtia sa stranicama baziranim na Markdownu je najlakši način za održavanje jednostavnosti za content stranice.
Authentication Store
Većina aplikacija ima neki oblik autentifikacije. Evo patterna koji možete ponovno koristiti:
// src/valtio/auth/auth.store.ts
import { proxy, useSnapshot } from 'valtio';
interface AuthStore {
user: User | null;
token: string | null;
isLoading: boolean;
}
export const authStore = proxy<AuthStore>({
user: null,
token: localStorage.getItem('token'),
isLoading: true,
});
export const useAuthStore = () => useSnapshot(authStore);
// src/valtio/auth/auth.actions.ts
import { AuthService } from '@/services';
import { authStore } from './auth.store';
export async function login(email: string, password: string) {
authStore.isLoading = true;
try {
const { user, token } = await AuthService.login(email, password);
authStore.user = user;
authStore.token = token;
localStorage.setItem('token', token);
return true;
} catch {
return false;
} finally {
authStore.isLoading = false;
}
}
export function logout() {
authStore.user = null;
authStore.token = null;
localStorage.removeItem('token');
}
export async function checkAuth() {
if (!authStore.token) {
authStore.isLoading = false;
return;
}
try {
authStore.user = await AuthService.me();
} catch {
logout();
} finally {
authStore.isLoading = false;
}
}
Za vraćanje sesija, pozovite checkAuth() kada se aplikacija pokrene.
Pretplata izvan Reacta
Valtio vam omogućuje pretplatu na stanje izvan komponenti. Dobro za logiranje, sinkronizaciju ili analitiku:
import { subscribe } from 'valtio';
// Prati sve promjene košarice
subscribe(cartStore, () => {
analytics.track('cart_updated', {
items: cartStore.items.length,
total: cartStore.total,
});
});
Koristite subscribeKey za informacije o određenim svojstvima:
import { subscribeKey } from 'valtio/utils';
subscribeKey(authStore, 'user', user => {
if (user) {
analytics.identify(user.id);
}
});
Izračunate vrijednosti s derive
Koristite derive za dobivanje vrijednosti iz stanja:
import { proxy } from 'valtio';
import { derive } from 'valtio/utils';
const cartStore = proxy({
items: [],
taxRate: 0.08,
});
const cartDerived = derive({
subtotal: get => get(cartStore).items.reduce((sum, item) => sum + item.price * item.quantity, 0),
tax: get => get(cartDerived).subtotal * get(cartStore).taxRate,
total: get => get(cartDerived).subtotal + get(cartDerived).tax,
});
Kada se ovisnosti promijene, izvedene vrijednosti se automatski ažuriraju.
Testiranje storeova
Testiranje Valtia je jednostavno jer su storeovi samo obični objekti:
import { clearSelection, selectUser } from './users.actions';
import { usersStore } from './users.store';
describe('UsersStore', () => {
beforeEach(() => {
// Resetiraj stanje prije svakog testa
usersStore.users = [];
usersStore.selectedUser = undefined;
});
it('odabire korisnika', () => {
const user = { id: '1', name: 'John' };
selectUser(user);
expect(usersStore.selectedUser).toEqual(user);
});
it('briše odabir', () => {
usersStore.selectedUser = { id: '1', name: 'John' };
clearSelection();
expect(usersStore.selectedUser).toBeUndefined();
});
});
Kao i uvijek, koristite Jest za lažiranje API poziva.
Savjeti za performanse
Razbijte velike storeove u manje koji su specifični za određena područja:
// Dobro: Fokusirani storeovi
const usersStore = proxy({ users: [], selected: null })
const productsStore = proxy({ products: [], categories: [] })
const cartStore = proxy({ items: [], total: 0 })
// Izbjegavajte: Jedan ogroman store
const appStore = proxy({ users: [], products: [], cart: [], ... })
Za ograničavanje rerenderiranja, destrukturirajte snapshotove:
// Komponenta se rerenderira samo kada se users promijene
const { users } = useSnapshot(usersStore);
// vs. rerenderiranje na bilo koju promjenu storea
const snap = useSnapshot(usersStore);
Kada koristiti Valtio
Dobar izbor:
- Male do srednje aplikacije
- Timovi koji ne žele puno boilerplatea
- Projekti gdje Redux djeluje pretjerano
- Brzo prototipiranje
Razmotrite alternative ako:
- Puno koristite Redux DevTools
- Vaš tim već dobro poznaje Redux
- Trebate middleware ekosustav







