Ders İçeriği

Neden State Yönetimi?

React uygulamaları büyüdükçe, bileşenler arasında veri paylaşımı karmaşık hale gelir. Küçük uygulamalarda props drilling (props'ları birden fazla seviye aşağı geçirme) yeterli olabilir, ancak büyük uygulamalarda bu yaklaşım sürdürülebilir değildir.

State yönetiminin gerekli olduğu durumlar şunlardır: kullanıcı kimlik doğrulama bilgileri, tema tercihleri, alışveriş sepeti içeriği, form verileri, API'den gelen veriler ve uygulama genelinde kullanılan ayarlar. Bu tür verilerin birden fazla bileşen tarafından kullanılması gerektiğinde, global state yönetimi devreye girer.

Global state yönetimi, uygulamanın herhangi bir yerinden erişilebilen ve değiştirilebilen merkezi bir veri deposu sağlar. Bu yaklaşım, kodun daha temiz, sürdürülebilir ve test edilebilir olmasını sağlar.

React ekosisteminde state yönetimi için çeşitli çözümler bulunur. En popüler olanları Context API (React'in yerleşik çözümü), Redux, Zustand, Recoil ve Jotai'dir. Bu derste Context API ve Redux üzerinde duracağız.

Context API ile Global State Yönetimi

Context API, React'in yerleşik state yönetimi çözümüdür. Props drilling problemini çözmek için tasarlanmıştır ve küçük-orta ölçekli uygulamalar için yeterlidir.

Temel Context Kullanımı

Basit bir tema yönetimi örneği ile başlayalım:

import React, { createContext, useContext, useState } from 'react';


// Context oluşturma

const ThemeContext = createContext();


// Provider bileşeni

export function ThemeProvider({ children }) {

  const [theme, setTheme] = useState('light');

  

  const toggleTheme = () => {

    setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light');

  };

  

  const value = {

    theme,

    toggleTheme,

    colors: {

      light: {

        background: '#ffffff',

        text: '#000000',

        primary: '#007bff'

      },

      dark: {

        background: '#121212',

        text: '#ffffff',

        primary: '#bb86fc'

      }

    }

  };

  

  return (

    <ThemeContext.Provider value={value}>

      {children}

    </ThemeContext.Provider>

  );

}


// Custom hook

export function useTheme() {

  const context = useContext(ThemeContext);

  if (context === undefined) {

    throw new Error('useTheme must be used within a ThemeProvider');

  }

  return context;

}


// Bileşenlerde kullanım

function Header() {

  const { theme, toggleTheme, colors } = useTheme();

  const currentColors = colors[theme];

  

  return (

    <header style={{

      backgroundColor: currentColors.background,

      color: currentColors.text,

      padding: '1rem'

    }}>

      <h1>Benim Uygulamam</h1>

      <button 

        onClick={toggleTheme}

        style={{

          backgroundColor: currentColors.primary,

          color: currentColors.background,

          border: 'none',

          padding: '0.5rem 1rem',

          borderRadius: '4px'

        }}

      >

        {theme === 'light' ? 'Koyu Tema' : 'Açık Tema'}

      </button>

    </header>

  );

}


function Content() {

  const { theme, colors } = useTheme();

  const currentColors = colors[theme];

  

  return (

    <main style={{

      backgroundColor: currentColors.background,

      color: currentColors.text,

      padding: '2rem',

      minHeight: '80vh'

    }}>

      <h2>İçerik Alanı</h2>

      <p>Mevcut tema: {theme}</p>

    </main>

  );

}


// Ana uygulama

function App() {

  return (

    <ThemeProvider>

      <div>

        <Header />

        <Content />

      </div>

    </ThemeProvider>

  );

}

Karmaşık State Yönetimi için useReducer

Daha karmaşık state mantığı için useReducer hook'u ile Context API'yi birleştirmek etkili bir yaklaşımdır:

import React, { createContext, useContext, useReducer } from 'react';


// Action types

const ACTIONS = {

  LOGIN: 'LOGIN',

  LOGOUT: 'LOGOUT',

  UPDATE_PROFILE: 'UPDATE_PROFILE',

  SET_LOADING: 'SET_LOADING',

  SET_ERROR: 'SET_ERROR',

  CLEAR_ERROR: 'CLEAR_ERROR'

};


// Initial state

const initialState = {

  user: null,

  isAuthenticated: false,

  loading: false,

  error: null

};


// Reducer function

function authReducer(state, action) {

  switch (action.type) {

    case ACTIONS.SET_LOADING:

      return {

        ...state,

        loading: action.payload,

        error: null

      };

      

    case ACTIONS.SET_ERROR:

      return {

        ...state,

        loading: false,

        error: action.payload

      };

      

    case ACTIONS.CLEAR_ERROR:

      return {

        ...state,

        error: null

      };

      

    case ACTIONS.LOGIN:

      return {

        ...state,

        user: action.payload,

        isAuthenticated: true,

        loading: false,

        error: null

      };

      

    case ACTIONS.LOGOUT:

      return {

        ...state,

        user: null,

        isAuthenticated: false,

        loading: false,

        error: null

      };

      

    case ACTIONS.UPDATE_PROFILE:

      return {

        ...state,

        user: {

          ...state.user,

          ...action.payload

        },

        loading: false,

        error: null

      };

      

    default:

      return state;

  }

}


// Context

const AuthContext = createContext();


// Provider

export function AuthProvider({ children }) {

  const [state, dispatch] = useReducer(authReducer, initialState);

  

  // Action creators

  const login = async (credentials) => {

    try {

      dispatch({ type: ACTIONS.SET_LOADING, payload: true });

      

      // API çağrısı simülasyonu

      const response = await fetch('/api/auth/login', {

        method: 'POST',

        headers: { 'Content-Type': 'application/json' },

        body: JSON.stringify(credentials)

      });

      

      if (!response.ok) {

        throw new Error('Giriş başarısız');

      }

      

      const userData = await response.json();

      

      // Token'ı localStorage'a kaydet

      localStorage.setItem('authToken', userData.token);

      

      dispatch({ type: ACTIONS.LOGIN, payload: userData.user });

    } catch (error) {

      dispatch({ type: ACTIONS.SET_ERROR, payload: error.message });

      throw error;

    }

  };

  

  const logout = () => {

    localStorage.removeItem('authToken');

    dispatch({ type: ACTIONS.LOGOUT });

  };

  

  const updateProfile = async (profileData) => {

    try {

      dispatch({ type: ACTIONS.SET_LOADING, payload: true });

      

      const response = await fetch('/api/user/profile', {

        method: 'PUT',

        headers: {

          'Content-Type': 'application/json',

          'Authorization': `Bearer ${localStorage.getItem('authToken')}`

        },

        body: JSON.stringify(profileData)

      });

      

      if (!response.ok) {

        throw new Error('Profil güncellenemedi');

      }

      

      const updatedUser = await response.json();

      dispatch({ type: ACTIONS.UPDATE_PROFILE, payload: updatedUser });

    } catch (error) {

      dispatch({ type: ACTIONS.SET_ERROR, payload: error.message });

      throw error;

    }

  };

  

  const clearError = () => {

    dispatch({ type: ACTIONS.CLEAR_ERROR });

  };

  

  // Token kontrolü (uygulama başlangıcında)

  useEffect(() => {

    const token = localStorage.getItem('authToken');

    if (token) {

      // Token geçerliliğini kontrol et

      verifyToken(token);

    }

  }, []);

  

  const verifyToken = async (token) => {

    try {

      dispatch({ type: ACTIONS.SET_LOADING, payload: true });

      

      const response = await fetch('/api/auth/verify', {

        headers: { 'Authorization': `Bearer ${token}` }

      });

      

      if (response.ok) {

        const userData = await response.json();

        dispatch({ type: ACTIONS.LOGIN, payload: userData });

      } else {

        localStorage.removeItem('authToken');

        dispatch({ type: ACTIONS.LOGOUT });

      }

    } catch (error) {

      localStorage.removeItem('authToken');

      dispatch({ type: ACTIONS.LOGOUT });

    }

  };

  

  const value = {

    ...state,

    login,

    logout,

    updateProfile,

    clearError

  };

  

  return (

    <AuthContext.Provider value={value}>

      {children}

    </AuthContext.Provider>

  );

}


// Custom hook

export function useAuth() {

  const context = useContext(AuthContext);

  if (context === undefined) {

    throw new Error('useAuth must be used within an AuthProvider');

  }

  return context;

}


// Kullanım örnekleri

function LoginForm() {

  const [credentials, setCredentials] = useState({ email: '', password: '' });

  const { login, loading, error, clearError } = useAuth();

  

  const handleSubmit = async (e) => {

    e.preventDefault();

    try {

      await login(credentials);

      // Başarılı giriş sonrası yönlendirme

      window.location.href = '/dashboard';

    } catch (error) {

      // Hata zaten context'te yönetiliyor

    }

  };

  

  useEffect(() => {

    return () => clearError(); // Component unmount olduğunda hataları temizle

  }, [clearError]);

  

  return (

    <form onSubmit={handleSubmit}>

      <h2>Giriş Yap</h2>

      

      {error && (

        <div style={{ color: 'red', marginBottom: '1rem' }}>

          {error}

        </div>

      )}

      

      <div>

        <label>

          E-posta:

          <input

            type="email"

            value={credentials.email}

            onChange={(e) => setCredentials({...credentials, email: e.target.value})}

            required

          />

        </label>

      </div>

      

      <div>

        <label>

          Şifre:

          <input

            type="password"

            value={credentials.password}

            onChange={(e) => setCredentials({...credentials, password: e.target.value})}

            required

          />

        </label>

      </div>

      

      <button type="submit" disabled={loading}>

        {loading ? 'Giriş yapılıyor...' : 'Giriş Yap'}

      </button>

    </form>

  );

}


function UserProfile() {

  const { user, isAuthenticated, updateProfile, loading } = useAuth();

  const [profileData, setProfileData] = useState({

    name: '',

    email: '',

    phone: ''

  });

  

  useEffect(() => {

    if (user) {

      setProfileData({

        name: user.name || '',

        email: user.email || '',

        phone: user.phone || ''

      });

    }

  }, [user]);

  

  const handleSubmit = async (e) => {

    e.preventDefault();

    try {

      await updateProfile(profileData);

      alert('Profil güncellendi!');

    } catch (error) {

      alert('Profil güncellenemedi!');

    }

  };

  

  if (!isAuthenticated) {

    return <div>Lütfen giriş yapın.</div>;

  }

  

  return (

    <form onSubmit={handleSubmit}>

      <h2>Profil Bilgileri</h2>

      

      <div>

        <label>

          İsim:

          <input

            type="text"

            value={profileData.name}

            onChange={(e) => setProfileData({...profileData, name: e.target.value})}

          />

        </label>

      </div>

      

      <div>

        <label>

          E-posta:

          <input

            type="email"

            value={profileData.email}

            onChange={(e) => setProfileData({...profileData, email: e.target.value})}

          />

        </label>

      </div>

      

      <div>

        <label>

          Telefon:

          <input

            type="tel"

            value={profileData.phone}

            onChange={(e) => setProfileData({...profileData, phone: e.target.value})}

          />

        </label>

      </div>

      

      <button type="submit" disabled={loading}>

        {loading ? 'Güncelleniyor...' : 'Profili Güncelle'}

      </button>

    </form>

  );

}

Birden Fazla Context Yönetimi

Büyük uygulamalarda farklı amaçlar için birden fazla context kullanmak yaygındır:

// Contexts'leri birleştiren provider

function AppProviders({ children }) {

  return (

    <AuthProvider>

      <ThemeProvider>

        <NotificationProvider>

          <ShoppingCartProvider>

            {children}

          </ShoppingCartProvider>

        </NotificationProvider>

      </ThemeProvider>

    </AuthProvider>

  );

}


// Ana uygulama

function App() {

  return (

    <AppProviders>

      <Router>

        <Routes>

          <Route path="/" element={<Home />} />

          <Route path="/login" element={<Login />} />

          <Route path="/profile" element={<Profile />} />

        </Routes>

      </Router>

    </AppProviders>

  );

}

Redux Nedir? Temel Kavramlar

Redux, JavaScript uygulamaları için öngörülebilir state container'ıdır. React ile birlikte sık kullanılsa da, framework-agnostic'tir ve herhangi bir JavaScript uygulamasında kullanılabilir.

Redux'un temel prensipleri şunlardır:

Single Source of Truth: Uygulamanın tüm state'i tek bir store'da tutulur. Bu, state'in öngörülebilir olmasını ve debug edilmesini kolaylaştırır.

State is Read-Only: State'i değiştirmenin tek yolu action dispatch etmektir. Bu, state değişikliklerinin izlenebilir olmasını sağlar.

Changes are Made with Pure Functions: State değişiklikleri reducer fonksiyonları ile yapılır. Reducer'lar pure function'lardır ve aynı input için her zaman aynı output'u verirler.

Redux Temel Kavramları

Store: Uygulamanın state'ini tutan obje. Tek bir store bulunur ve tüm state burada tutulur.

Action: State'te yapılacak değişikliği tanımlayan plain JavaScript objesi. Her action'ın bir type property'si olmalıdır.

Reducer: Action'ları alıp yeni state döndüren pure function. Mevcut state'i değiştirmez, yeni state objesi döndürür.

Dispatch: Action'ları store'a gönderme işlemi. Store, action'ı ilgili reducer'a iletir.

Redux Kurulumu ve Temel Kullanım

bash npm install @reduxjs/toolkit react-redux

Redux Toolkit, Redux'un modern ve önerilen kullanım şeklidir. Daha az boilerplate kod gerektirir ve best practice'leri varsayılan olarak uygular.

// store/slices/counterSlice.js

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


const counterSlice = createSlice({

  name: 'counter',

  initialState: {

    value: 0,

    history: []

  },

  reducers: {

    increment: (state) => {

      state.value += 1;

      state.history.push({ action: 'increment', timestamp: Date.now() });

    },

    decrement: (state) => {

      state.value -= 1;

      state.history.push({ action: 'decrement', timestamp: Date.now() });

    },

    incrementByAmount: (state, action) => {

      state.value += action.payload;

      state.history.push({ 

        action: 'incrementByAmount', 

        amount: action.payload, 

        timestamp: Date.now() 

      });

    },

    reset: (state) => {

      state.value = 0;

      state.history.push({ action: 'reset', timestamp: Date.now() });

    }

  }

});


export const { increment, decrement, incrementByAmount, reset } = counterSlice.actions;

export default counterSlice.reducer;


// store/slices/todoSlice.js

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


// Async thunk for API calls

export const fetchTodos = createAsyncThunk(

  'todos/fetchTodos',

  async (_, { rejectWithValue }) => {

    try {

      const response = await fetch('/api/todos');

      if (!response.ok) {

        throw new Error('Failed to fetch todos');

      }

      return await response.json();

    } catch (error) {

      return rejectWithValue(error.message);

    }

  }

);


export const addTodo = createAsyncThunk(

  'todos/addTodo',

  async (todoText, { rejectWithValue }) => {

    try {

      const response = await fetch('/api/todos', {

        method: 'POST',

        headers: { 'Content-Type': 'application/json' },

        body: JSON.stringify({ text: todoText, completed: false })

      });

      if (!response.ok) {

        throw new Error('Failed to add todo');

      }

      return await response.json();

    } catch (error) {

      return rejectWithValue(error.message);

    }

  }

);


const todoSlice = createSlice({

  name: 'todos',

  initialState: {

    items: [],

    loading: false,

    error: null,

    filter: 'all' // all, active, completed

  },

  reducers: {

    toggleTodo: (state, action) => {

      const todo = state.items.find(item => item.id === action.payload);

      if (todo) {

        todo.completed = !todo.completed;

      }

    },

    deleteTodo: (state, action) => {

      state.items = state.items.filter(item => item.id !== action.payload);

    },

    setFilter: (state, action) => {

      state.filter = action.payload;

    },

    clearCompleted: (state) => {

      state.items = state.items.filter(item => !item.completed);

    }

  },

  extraReducers: (builder) => {

    builder

      // Fetch todos

      .addCase(fetchTodos.pending, (state) => {

        state.loading = true;

        state.error = null;

      })

      .addCase(fetchTodos.fulfilled, (state, action) => {

        state.loading = false;

        state.items = action.payload;

      })

      .addCase(fetchTodos.rejected, (state, action) => {

        state.loading = false;

        state.error = action.payload;

      })

      // Add todo

      .addCase(addTodo.pending, (state) => {

        state.loading = true;

      })

      .addCase(addTodo.fulfilled, (state, action) => {

        state.loading = false;

        state.items.push(action.payload);

      })

      .addCase(addTodo.rejected, (state, action) => {

        state.loading = false;

        state.error = action.payload;

      });

  }

});


export const { toggleTodo, deleteTodo, setFilter, clearCompleted } = todoSlice.actions;

export default todoSlice.reducer;


// store/index.js

import { configureStore } from '@reduxjs/toolkit';

import counterReducer from './slices/counterSlice';

import todoReducer from './slices/todoSlice';


export const store = configureStore({

  reducer: {

    counter: counterReducer,

    todos: todoReducer

  },

  middleware: (getDefaultMiddleware) =>

    getDefaultMiddleware({

      serializableCheck: {

        ignoredActions: ['persist/PERSIST']

      }

    })

});


export type RootState = ReturnType<typeof store.getState>;

export type AppDispatch = typeof store.dispatch;


// hooks/redux.js

import { useDispatch, useSelector, TypedUseSelectorHook } from 'react-redux';

import type { RootState, AppDispatch } from '../store';


export const useAppDispatch = () => useDispatch<AppDispatch>();

export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;


// App.js

import React from 'react';

import { Provider } from 'react-redux';

import { store } from './store';

import Counter from './components/Counter';

import TodoApp from './components/TodoApp';


function App() {

  return (

    <Provider store={store}>

      <div className="App">

        <Counter />

        <TodoApp />

      </div>

    </Provider>

  );

}


export default App;


// components/Counter.js

import React, { useState } from 'react';

import { useAppDispatch, useAppSelector } from '../hooks/redux';

import { increment, decrement, incrementByAmount, reset } from '../store/slices/counterSlice';


function Counter() {

  const count = useAppSelector(state => state.counter.value);

  const history = useAppSelector(state => state.counter.history);

  const dispatch = useAppDispatch();

  

  const [incrementAmount, setIncrementAmount] = useState(5);

  

  return (

    <div style={{ padding: '20px', border: '1px solid #ccc', margin: '20px' }}>

      <h2>Counter: {count}</h2>

      

      <div style={{ marginBottom: '10px' }}>

        <button onClick={() => dispatch(increment())}>+</button>

        <button onClick={() => dispatch(decrement())} style={{ marginLeft: '10px' }}>-</button>

        <button onClick={() => dispatch(reset())} style={{ marginLeft: '10px' }}>Reset</button>

      </div>

      

      <div style={{ marginBottom: '10px' }}>

        <input

          type="number"

          value={incrementAmount}

          onChange={(e) => setIncrementAmount(Number(e.target.value))}

        />

        <button 

          onClick={() => dispatch(incrementByAmount(incrementAmount))}

          style={{ marginLeft: '10px' }}

        >

          Add Amount

        </button>

      </div>

      

      <div>

        <h3>History:</h3>

        <ul style={{ maxHeight: '100px', overflowY: 'auto' }}>

          {history.slice(-5).map((entry, index) => (

            <li key={index}>

              {entry.action} {entry.amount && `(${entry.amount})`} - {new Date(entry.timestamp).toLocaleTimeString()}

            </li>

          ))}

        </ul>

      </div>

    </div>

  );

}


export default Counter;


// components/TodoApp.js

import React, { useEffect, useState } from 'react';

import { useAppDispatch, useAppSelector } from '../hooks/redux';

import { 

  fetchTodos, 

  addTodo, 

  toggleTodo, 

  deleteTodo, 

  setFilter, 

  clearCompleted 

} from '../store/slices/todoSlice';


function TodoApp() {

  const { items, loading, error, filter } = useAppSelector(state => state.todos);

  const dispatch = useAppDispatch();

  const [newTodoText, setNewTodoText] = useState('');

  

  useEffect(() => {

    dispatch(fetchTodos());

  }, [dispatch]);

  

  const handleAddTodo = async (e) => {

    e.preventDefault();

    if (newTodoText.trim()) {

      await dispatch(addTodo(newTodoText.trim()));

      setNewTodoText('');

    }

  };

  

  const filteredTodos = items.filter(todo => {

    if (filter === 'active') return !todo.completed;

    if (filter === 'completed') return todo.completed;

    return true;

  });

  

  const activeCount = items.filter(todo => !todo.completed).length;

  const completedCount = items.filter(todo => todo.completed).length;

  

  return (

    <div style={{ padding: '20px', border: '1px solid #ccc', margin: '20px' }}>

      <h2>Todo App</h2>

      

      {error && (

        <div style={{ color: 'red', marginBottom: '10px' }}>

          Error: {error}

        </div>

      )}

      

      <form onSubmit={handleAddTodo} style={{ marginBottom: '20px' }}>

        <input

          type="text"

          value={newTodoText}

          onChange={(e) => setNewTodoText(e.target.value)}

          placeholder="Add a new todo..."

          disabled={loading}

        />

        <button type="submit" disabled={loading || !newTodoText.trim()}>

          {loading ? 'Adding...' : 'Add Todo'}

        </button>

      </form>

      

      <div style={{ marginBottom: '20px' }}>

        <button 

          onClick={() => dispatch(setFilter('all'))}

          style={{ 

            backgroundColor: filter === 'all' ? '#007bff' : '#f8f9fa',

            color: filter === 'all' ? 'white' : 'black'

          }}

        >

          All ({items.length})

        </button>

        <button 

          onClick={() => dispatch(setFilter('active'))}

          style={{ 

            marginLeft: '10px',

            backgroundColor: filter === 'active' ? '#007bff' : '#f8f9fa',

            color: filter === 'active' ? 'white' : 'black'

          }}

        >

          Active ({activeCount})

        </button>

        <button 

          onClick={() => dispatch(setFilter('completed'))}

          style={{ 

            marginLeft: '10px',

            backgroundColor: filter === 'completed' ? '#007bff' : '#f8f9fa',

            color: filter === 'completed' ? 'white' : 'black'

          }}

        >

          Completed ({completedCount})

        </button>

        

        {completedCount > 0 && (

          <button 

            onClick={() => dispatch(clearCompleted())}

            style={{ marginLeft: '20px', color: 'red' }}

          >

            Clear Completed

          </button>

        )}

      </div>

      

      {loading && <div>Loading todos...</div>}

      

      <ul style={{ listStyle: 'none', padding: 0 }}>

        {filteredTodos.map(todo => (

          <li 

            key={todo.id} 

            style={{ 

              padding: '10px', 

              borderBottom: '1px solid #eee',

              display: 'flex',

              alignItems: 'center',

              justifyContent: 'space-between'

            }}

          >

            <div style={{ display: 'flex', alignItems: 'center' }}>

              <input

                type="checkbox"

                checked={todo.completed}

                onChange={() => dispatch(toggleTodo(todo.id))}

                style={{ marginRight: '10px' }}

              />

              <span 

                style={{ 

                  textDecoration: todo.completed ? 'line-through' : 'none',

                  color: todo.completed ? '#666' : 'black'

                }}

              >

                {todo.text}

              </span>

            </div>

            <button 

              onClick={() => dispatch(deleteTodo(todo.id))}

              style={{ color: 'red', border: 'none', background: 'none', cursor: 'pointer' }}

            >

              Delete

            </button>

          </li>

        ))}

      </ul>

      

      {filteredTodos.length === 0 && !loading && (

        <div style={{ textAlign: 'center', color: '#666', marginTop: '20px' }}>

          {filter === 'all' ? 'No todos yet' : `No ${filter} todos`}

        </div>

      )}

    </div>

  );

}


export default TodoApp;

Redux, büyük ve karmaşık uygulamalar için güçlü bir state yönetimi çözümüdür. Context API ise küçük-orta ölçekli uygulamalar için yeterlidir. Hangi yaklaşımı seçeceğiniz, uygulamanızın karmaşıklığına, takım büyüklüğüne ve gereksinimlere bağlıdır. Her iki yaklaşımın da avantajları ve dezavantajları vardır, bu nedenle proje gereksinimlerinizi dikkatlice değerlendirerek karar vermeniz önemlidir.