State Mantığını Bir Reducer'a Aktarma

Birçok olay yöneticisine yayılmış çok fazla sayıda state güncellemesine sahip bileşenler can sıkıcı olabilir. Bu gibi durumlarda tüm state güncelleme mantıklarını reducer (redüktör) adı verilen tek bir fonksiyonda birleştirebilirsiniz.

Bunları öğreneceksiniz

  • Bir reducer fonsiyonunun ne olduğu
  • useState’i useReducer ile nasıl yeniden yapılandıracağınız
  • Ne zaman bir reducer kullanmanız gerektiği
  • İyi bir reducer yazmanın püf noktaları

State mantığını (State logic) bir reducer ile birleştirin

Bileşenlerinizin karmaşıklığı arttıkça bir bileşenin state’inin hangi farklı yollarla güncellendiğini bir bakışta görmek zorlaşabilir. Örneğin, aşağıdaki TaskApp bileşeni görevler dizinini bir state’de tutar ve görevleri eklemek, kaldırmak ve düzenlemek için üç farklı olay yöneticisi kullanır:

import { useState } from 'react';
import AddTask from './AddTask.js';
import TaskList from './TaskList.js';

export default function TaskApp() {
  const [tasks, setTasks] = useState(initialTasks);

  function handleAddTask(text) {
    setTasks([
      ...tasks,
      {
        id: nextId++,
        text: text,
        done: false,
      },
    ]);
  }

  function handleChangeTask(task) {
    setTasks(
      tasks.map((t) => {
        if (t.id === task.id) {
          return task;
        } else {
          return t;
        }
      })
    );
  }

  function handleDeleteTask(taskId) {
    setTasks(tasks.filter((t) => t.id !== taskId));
  }

  return (
    <>
      <h1>Prag Gezisi Planı</h1>
      <AddTask onAddTask={handleAddTask} />
      <TaskList
        tasks={tasks}
        onChangeTask={handleChangeTask}
        onDeleteTask={handleDeleteTask}
      />
    </>
  );
}

let nextId = 3;
const initialTasks = [
  {id: 0, text: 'Kafka Müzesini ziyaret et', done: true},
  {id: 1, text: 'Kukla gösterisi izle', done: false},
  {id: 2, text: "Lennon Duvarı'nda fotoğraf çek", done: false},
];

Her bir olay yöneticisi state’i güncellemek için setTasks’ı çağırır. Bu bileşen büyüdükçe, içine serpiştirilmiş state mantığı miktarı da artar. Bu karmaşıklığı azaltmak ve tüm mantığı erişilmesi kolay tek bir yerde tutmak için state mantıklarını bileşeninizin dışında “reducer” adı verilen tek bir fonksiyona taşıyabilirsiniz.

Reducer’lar, state’i ele almanın farklı bir yöntemidir. useState’ten useReducer’a şu üç adımda geçebilirsiniz:

  1. State ayarlamak yerine işlemleri göndermeye (dispatching) geçme.
  2. Bir reducer fonksiyonu yazma.
  3. Bileşeninizden gelen “reducer”ı kullanma.

Step 1: State ayarlamak yerine işlemleri göndermeye (dispatching) geçme

Olay yöneticileriniz şu aşamada ne yapılacağını state ayarlayarak belirler:

function handleAddTask(text) {
setTasks([
...tasks,
{
id: nextId++,
text: text,
done: false,
},
]);
}

function handleChangeTask(task) {
setTasks(
tasks.map((t) => {
if (t.id === task.id) {
return task;
} else {
return t;
}
})
);
}

function handleDeleteTask(taskId) {
setTasks(tasks.filter((t) => t.id !== taskId));
}

Tüm state ayarlama mantığını kaldırın. Geriye üç olay yöneticisi kalacaktır:

  • handleAddTask(text) kullanıcı “Ekle” butonuna bastığı zaman çağrılır.
  • handleChangeTask(task) kullanıcı bir görevi açıp kapattığında veya “Kaydet” butonuna bastığında çağrılır.
  • handleDeleteTask(taskId) kullanıcı “Sil” butonuna bastığında çağrılır.

Reducer’lar ile state yönetimi doğrudan state’i ayarlama işleminden biraz farklıdır. State ayarlayarak React’e “ne yapılacağını” belirtmek yerine, olay yöneticilerinden “işlemler” göndererek “kullanıcının ne yaptığını” belirtirsiniz. (State güncelleme mantığı başka bir yerde yaşayacaktır!) Yani bir olay yöneticisi aracılığıyla “görevleri ayarlamak” yerine, “görev eklendi/değiştirildi/silindi” şeklinde bir işlem gönderirsiniz. Bu kullanıcının isteğini daha açık hale getirir.

function handleAddTask(text) {
dispatch({
type: 'added',
id: nextId++,
text: text,
});
}

function handleChangeTask(task) {
dispatch({
type: 'changed',
task: task,
});
}

function handleDeleteTask(taskId) {
dispatch({
type: 'deleted',
id: taskId,
});
}

dispatch’e gönderdiğiniz nesneye “işlem” adı verilir.

function handleDeleteTask(taskId) {
dispatch(
// "işlem" nesnesi:
{
type: 'deleted',
id: taskId,
}
);
}

Bu bildiğimiz bir JavaScript nesnesidir. İçine ne koyacağınıza siz karar verirsiniz, ancak genellikle ne meydana geldiği hakkında minimum bilgi içermelidir. (dispatch fonksiyonunun kendisini daha sonraki bir adımda ekleyeceksiniz.)

Not

Bir işlem nesnesi herhangi bir şekle sahip olabilir.

Geleneksel olarak, ona ne olduğunu açıklayan bir string type’ı vermek ve diğer alanlara herhangi bir ek bilgi girmek yaygındır. type bir bileşene özgüdür, bu nedenle bu örnek için 'added' veya 'added_task' uygun olacaktır. Ne olup bittiğini anlatan bir isim seçin!

dispatch({
// bileşene özgüdür
type: 'what_happened',
// diğer alanlar buraya girilir
});

Step 2: Bir reducer fonksiyonu yazma

Bir reducer fonksiyonu state mantığınızı (state logic) koyacağınız yerdir. İki argüman alır; mevcut state ve işlem nesnesi, ardından bir sonraki state’i geri döndürür:

function yourReducer(state, action) {
// React'in ayarlaması için bir sonraki state'i geri döndür
}

React reducer’dan ne geri döndürürseniz state’i ona göre ayarlayacaktır.

Bu örnekte state ayarlama mantığınızı olay yöneticilerinden bir reducer fonksiyonuna taşımak için şunları yapacaksınız:

  1. Geçerli state’i (tasks) ilk argüman olarak tanımlayın.
  2. action nesnesini ikinci argüman olarak tanımlayın.
  3. Reducer’dan (React’in state’i ayarlayacağı) bir sonraki state’i geri döndürün.

Burada tüm state ayarlama mantığı tek bir reducer fonksiyonuna aktarılmıştır:

function tasksReducer(tasks, action) {
if (action.type === 'added') {
return [
...tasks,
{
id: action.id,
text: action.text,
done: false,
},
];
} else if (action.type === 'changed') {
return tasks.map((t) => {
if (t.id === action.task.id) {
return action.task;
} else {
return t;
}
});
} else if (action.type === 'deleted') {
return tasks.filter((t) => t.id !== action.id);
} else {
throw Error('Bilinmeyen işlem: ' + action.type);
}
}

Reducer fonksiyonu state’i (tasks) bir argüman olarak aldığından bunu bileşeninizin dışında tanımlayabilirsiniz. Bu satır girinti seviyesini azaltır ve kodunuzun okunmasını kolaylaştırır.

Not

Yukarıdaki kod if/else ifadesini kullanır ancak switch ifadesini reducer’ların içinde kullanmak bir gelenektir. Sonuç aynıdır, ancak switch ifadelerini bir bakışta okumak daha kolay olabilir.

Bu dökümantasyonun geri kalanında bu şekilde kullanacağız:

function tasksReducer(tasks, action) {
switch (action.type) {
case 'added': {
return [
...tasks,
{
id: action.id,
text: action.text,
done: false,
},
];
}
case 'changed': {
return tasks.map((t) => {
if (t.id === action.task.id) {
return action.task;
} else {
return t;
}
});
}
case 'deleted': {
return tasks.filter((t) => t.id !== action.id);
}
default: {
throw Error('Bilinmeyen işlem: ' + action.type);
}
}
}

Farklı case’ler içinde bildirilen değişkenlerin birbiriyle çakışmaması için her case bloğunu { ve } küme parantezlerine sarmanızı öneririz. Ayrıca bir case genellikle bir return ile bitmelidir. Eğer return’u unutursanız, kod bir sonraki case’e “düşer” ve bu da hatalara yol açabilir!

Eğer switch ifadeleri konusunda henüz rahat değilseniz, if/else kullanmaya devam edebilirsiniz.

Derinlemesine İnceleme

Reducer’lar (redüktör) neden bu şekilde adlandırılır?

Reducer’lar bileşeninizin içindeki kod miktarını “azaltabilir” (reduce) olsa da, aslında diziler üzerinde gerçekleştirebileceğiniz reduce() metodundan almakta.

reduce() işlemi bir diziyi alıp birçok değeri tek bir değerde “toplamanızı” sağlar:

const arr = [1, 2, 3, 4, 5];
const sum = arr.reduce(
(result, number) => result + number
); // 1 + 2 + 3 + 4 + 5

reduce’a aktardığınız fonksiyon “reducer” olarak bilinir. Bu fonksiyon o ana kadarki sonucu ve geçerli öğeyi alır, ardından bir sonraki sonucu geri döndürür. React reducer’lar da aynı fikrin bir örneğidir: o ana kadarki state’i ve işlemi alırlar ve bir sonraki state’i geri döndürürler. Bu şekilde, işlemleri zaman içinde state olarak toplarlar.

Hatta reduce() metodunu bir initialState ve bir actions dizisi ile kullanabilir ve reducer fonksiyonunuzu bu metoda aktararak son state’i hesaplayabilirsiniz:

import tasksReducer from './tasksReducer.js';

let initialState = [];
let actions = [
  {type: 'added', id: 1, text: 'Kafka Müzesini ziyaret et'},
  {type: 'added', id: 2, text: 'Kukla gösterisi izle'},
  {type: 'deleted', id: 1},
  {type: 'added', id: 3, text: "Lennon Duvarı'nda fotoğraf çek"},
];

let finalState = actions.reduce(tasksReducer, initialState);

const output = document.getElementById('output');
output.textContent = JSON.stringify(finalState, null, 2);

Muhtemelen bunu kendiniz yapmanız gerekmeyecektir, ancak bu React’in yaptığına benzer!

Step 3: Bileşeninizden gelen “reducer”ı kullanma

En son olarak, tasksReducer’ı bileşeninize bağlamanız gerekiyor. useReducer Hook’unu React’ten içe aktarın:

import { useReducer } from 'react';

Daha sonra useState’i:

const [tasks, setTasks] = useState(initialTasks);

useReducer ile şu şekilde değiştirebilirsiniz:

const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);

useReducer hook’u useState’e benzer; ona bir başlangıç state’i iletmeniz gerekir ve o da state bilgisi olan bir değeri geri döndürür (bu durumda dispatch fonksiyonu). Fakat yine de biraz faklılıklar gösterir.

useReducer hook’u iki argüman alır:

  1. Bir reduce fonksiyonu
  2. Bir başlangıç state’i

Ve şunları geri döndürür:

  1. State bilgisi içeren bir değer
  2. Bir dispatch fonksiyonu (kullanıcı işlemleri reducer’a “göndermek” için)

Artık tüm bağlantılar kurulmuş halde! Reducer burada bileşen dosyasının en altında tanımlanmıştır:

import { useReducer } from 'react';
import AddTask from './AddTask.js';
import TaskList from './TaskList.js';

export default function TaskApp() {
  const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);

  function handleAddTask(text) {
    dispatch({
      type: 'added',
      id: nextId++,
      text: text,
    });
  }

  function handleChangeTask(task) {
    dispatch({
      type: 'changed',
      task: task,
    });
  }

  function handleDeleteTask(taskId) {
    dispatch({
      type: 'deleted',
      id: taskId,
    });
  }

  return (
    <>
      <h1>Prag Gezisi Planı</h1>
      <AddTask onAddTask={handleAddTask} />
      <TaskList
        tasks={tasks}
        onChangeTask={handleChangeTask}
        onDeleteTask={handleDeleteTask}
      />
    </>
  );
}

function tasksReducer(tasks, action) {
  switch (action.type) {
    case 'added': {
      return [
        ...tasks,
        {
          id: action.id,
          text: action.text,
          done: false,
        },
      ];
    }
    case 'changed': {
      return tasks.map((t) => {
        if (t.id === action.task.id) {
          return action.task;
        } else {
          return t;
        }
      });
    }
    case 'deleted': {
      return tasks.filter((t) => t.id !== action.id);
    }
    default: {
      throw Error('Bilinmeyen işlem: ' + action.type);
    }
  }
}

let nextId = 3;
const initialTasks = [
  {id: 0, text: 'Kafka Müzesini ziyaret et', done: true},
  {id: 1, text: 'Kukla gösterisi izle', done: false},
  {id: 2, text: "Lennon Duvarı'nda fotoğraf çek", done: false},
];

Hatta isterseniz reducer’ı ayrı bir dosyaya da taşıyabilirsiniz:

import { useReducer } from 'react';
import AddTask from './AddTask.js';
import TaskList from './TaskList.js';
import tasksReducer from './tasksReducer.js';

export default function TaskApp() {
  const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);

  function handleAddTask(text) {
    dispatch({
      type: 'added',
      id: nextId++,
      text: text,
    });
  }

  function handleChangeTask(task) {
    dispatch({
      type: 'changed',
      task: task,
    });
  }

  function handleDeleteTask(taskId) {
    dispatch({
      type: 'deleted',
      id: taskId,
    });
  }

  return (
    <>
      <h1>Prag Gezisi Planı</h1>
      <AddTask onAddTask={handleAddTask} />
      <TaskList
        tasks={tasks}
        onChangeTask={handleChangeTask}
        onDeleteTask={handleDeleteTask}
      />
    </>
  );
}

let nextId = 3;
const initialTasks = [
  {id: 0, text: 'Kafka Müzesini ziyaret et', done: true},
  {id: 1, text: 'Kukla gösterisi izle', done: false},
  {id: 2, text: "Lennon Duvarı'nda fotoğraf çek", done: false},
];

Bileşen mantığını bu şekilde ayırmak okunarlığı daha kolay hale getirebilir. Artık olay yöneticileri işlemleri göndererek yalnızca ne olduğunu belirler ve reducer fonksiyonu bunlara yanıt olarak state’in nasıl güncelleneceği kararını verir.

useState ve useReducer karışılaştırması

Reducers’ların dezavantajları da yok değil! İşte bunları karşılaştırabileceğiniz birkaç yol:

  • Kod miktarı: Genellikle useState ile ilk etapta daha az kod yazmanız gerekir. useReducer ile ise hem reducer fonksiyonu hem de dispatch işlemlerini yazmanız gerekir. Ne var ki, birçok olay yöneticisi state’i benzer şekillerde değiştiriyorsa, useReducer kodu azaltmaya yardımcı olabilir.
  • Okunabilirlik: useState state güncellemeleri basit olduğu zamanlarda okunması da kolaydır. Daha karmaşık hale geldiklerinde ise bileşeninizin kodunu şişirebilir ve taranmasını zorlaştırabilir. Bu gibi durumlarda useReducer, güncelleme mantığının nasıl olduğunu, olay yöneticilerinin ne olduğundan temiz bir şekilde ayırmanızı sağlar.
  • Hata ayıklama: useState ile ilgili bir hatanız olduğunda, state’in nerede ve neden yanlış ayarlandığını söylemek zor olabilir. useReducer ile, her state güncellemesini ve bunun neden olduğunu (hangi işlem’den kaynaklandığını) görmek için reducer’ınıza bir konsol logu ekleyebilirsiniz. Eğer bütün işlemler doğruysa, hatanın reducer mantığının kendisinde olduğunu bilirsiniz. Ancak, useState ile olduğundan daha fazla kod üzerinden geçmeniz gerekir.
  • Test etme: Reducer, bileşeninize bağlı olmayan saf bir fonksiyondur. Bu, onu izole olarak ayrı ayrı dışa aktarabileceğiniz ve test edebileceğiniz anlamına gelir. Genellikle bileşenleri daha gerçekçi bir ortamda test etmek en iyisi olsa da karmaşık state güncelleme mantığı için reducer’unuzun belirli bir başlangıç state’i ve işlem için belirli bir state döndürdüğünü doğrulamak yararlı olabilir.
  • Kişisel tercih: Bazı insanlar reducer’ları sever, bazıları sevmez. Bu sorun değil. Bu bir tercih meselesidir. Her zaman useState ve useReducer arasında dönüşümlü olarak geçiş yapabilirsiniz: bunlar eşdeğerdir!

Bazı bileşenlerde yanlış state güncellemeleri nedeniyle sık sık hatalarla karşılaşıyorsanız ve koda daha fazla yapılandırma getirmek istiyorsanız bir reducer kullanmanızı öneririz. Her şey için reducer kullanmak zorunda değilsiniz: farklı kombinler yapmaktan çekinmeyin! Hatta aynı bileşende useState ve useReducer bile kullanabilirsiniz.

İyi bir reducer yazmak

Reducer yazarken şu iki ipucunu aklınızda bulundurun:

  • Reducer’lar saf olmalıdır. State güncelleme fonksiyonları gibi, reducer’lar da render sırasında çalışır! (İşlemler bir sonraki render işlemine kadar sıraya alınır.) Bunun anlamı reducer’ların saf olması gerektiğidir; aynı girdiler her zaman aynı çıktıyla sonuçlanır. Bunlar istek göndermemeli, zaman aşımı planlamamalı veya herhangi bir yan etki (bileşenin dışındaki şeyleri etkileyen faaliyetler) gerçekleştirmemelidir. Nesneleri ve dizileri mutasyon (değişinim) olmadan güncellemelidirler.
  • Her işlem verilerde birden fazla değişikliğe yol açsa bile tek bir kullanıcı etkileşimini ifade eder. Örneğin, bir reducer tarafından yönetilen beş alana sahip bir formda bir kullanıcı “Sıfırla” düğmesine bastığında, beş ayrı set_field işlemi yerine tek bir reset_form işlemini göndermek daha mantıklıdır. Bir reducer’daki her işlemi loglarsanız, bu log hangi etkileşimlerin veya yanıtların hangi sırayla gerçekleştiğini yeniden yapılandırmanız için yeterince açık olmalıdır. Bu, hata ayıklamaya yardımcı olur!

Immer ile kısa reducer’lar yazma

Aynı normal state’deki nesneleri ve dizileri güncelleme gibi, Immer kütüphanesini reducer’ları daha kısa ve öz hale getirmek için kullanabilirsiniz. Burada, useImmerReducer işlevi push veya arr[i] = ataması ile state’i değiştirmenizi sağlar:

{
  "dependencies": {
    "immer": "1.7.3",
    "react": "latest",
    "react-dom": "latest",
    "react-scripts": "latest",
    "use-immer": "0.5.1"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test --env=jsdom",
    "eject": "react-scripts eject"
  },
  "devDependencies": {}
}

Reducer’lar saf olmalıdır, dolayısıyla state’i değiştirmemelidirler. Ancak Immer size mutasyona uğraması güvenli olan özel bir draft nesnesi sağlar. Arka planda, Immer draft’ta yaptığınız değişikliklerle state’inizin bir kopyasını oluşturacaktır. Bu nedenle, useImmerReducer tarafından yönetilen reducer’lar ilk argümanlarını değiştirebilir ve state geri döndürmeleri gerekmez.

Özet

  • useState’ten useReducer’a geçmek için:
    1. İşlemlerinizi olay yöneticilerinden gönderin.
    2. Belirli bir state ve action için bir sonraki state’i döndüren bir reducer fonksiyonu yazın.
    3. useState’i useReducer ile değiştirin.
  • Reducer’lar biraz daha fazla kod yazmanızı gerektirse de hata ayıklama ve test etme konusunda yardımcı olurlar.
  • Reducer’lar saf olmalıdır.
  • Her işlem tek bir kullanıcı etkileşimini ifade eder.
  • Reducer’ları mutasyona uğrayan bir biçimde yazmak istiyorsanız Immer kullanın.

Problem 1 / 4:
İşlemleri olay yöneticisinden gönderin

Şu anda, ContactList.js ve Chat.js içindeki olay yöneticilerinde // TODO yorumları var. Bu nedenle yazı alanına yazma özelliği çalışmıyor ve düğmelere tıklamak seçilen alıcı kişiyi değiştirmiyor.

Bu iki // TODO’yu ilgili işlemleri (actions) dispatch edecek kodla değiştirin. İşlemlerin beklenen şeklini ve türünü görmek için messengerReducer.js dosyasındaki reducer’u kontrol edin. Reducer hali hazırda yazılmıştır, bu yüzden değiştirmenize gerek yoktur. Yalnızca ContactList.js ve Chat.js içindeki işlemleri göndermeniz (dispatch) gerekir.

import { useReducer } from 'react';
import Chat from './Chat.js';
import ContactList from './ContactList.js';
import { initialState, messengerReducer } from './messengerReducer';

export default function Messenger() {
  const [state, dispatch] = useReducer(messengerReducer, initialState);
  const message = state.message;
  const contact = contacts.find((c) => c.id === state.selectedId);
  return (
    <div>
      <ContactList
        contacts={contacts}
        selectedId={state.selectedId}
        dispatch={dispatch}
      />
      <Chat
        key={contact.id}
        message={message}
        contact={contact}
        dispatch={dispatch}
      />
    </div>
  );
}

const contacts = [
  {id: 0, name: 'Deniz', email: 'deniz@mail.com'},
  {id: 1, name: 'Aylin', email: 'aylin@mail.com'},
  {id: 2, name: 'Ata', email: 'ata@mail.com'},
];