Musa Yılmaz
·6 dk okuma

Django REST + Next.js: Şelale Olmadan Veri Çekmek

nextjsdjangorest-frameworkserver-components

Django REST Framework'e konuşan bir Server Component genelde aynı şekilde başlar: önce bir kaynağı çek, sonra ona bağlı olan bir sonrakini çek, sonra bir sonrakini. İkinci çağrı gerçekten birincinin verisine ihtiyaç duyuyorsa bu doğru bir sıralama. Çoğu zaman ihtiyaç duymaz, ama kod yine de yukarıdan aşağı öyleymiş gibi okunur — yani hiçbir şey bu sırayı zorunlu kılmadığı hâlde Django'ya giden her istek bir öncekinin bitmesini bekler.

Bu, Server Component'lerde oturum çerezi ve tek origin kurulumu yazılarında kullandığım aynı DRF backend'i. Bu yazı, auth ve routing hallolduktan sonra fetch çağrılarının kendi şekliyle ilgili.

Gözünün önündeki şelale

Bir proje panosu düşün: projenin kendi alanları, bir de altındaki görev listesi.

async function getProject(id: string) {
  const res = await apiFetch(`/api/projects/${id}/`);
  return res.json();
}
 
async function getTasks(id: string) {
  const res = await apiFetch(`/api/projects/${id}/tasks/`);
  return res.json();
}
 
export default async function ProjectPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const project = await getProject(id); // burada bekler
  const tasks = await getTasks(id); // sonra başlar
  return <ProjectView project={project} tasks={tasks} />;
}

getTasks, getProject'in döndürdüğü hiçbir şeye ihtiyaç duymuyor — görev listesi sadece URL'deki id'yi istiyor. Ama ikinci await aynı fonksiyonda birincinin ardına yazıldığı için, runtime bu isteği birincisi tamamen sonuçlanana kadar başlatmıyor: Django'nun isteği alması, queryset'i çalıştırması, yanıtı serialize etmesi ve baytların ağdan geri gelmesi. Next.js dokümanı bunu sıralı/paralel fetch örneklerinde açıkça söylüyor: layout'lar ve sayfalar varsayılan olarak paralel render edilse bile, "herhangi bir component içinde, art arda yazılmış birden fazla async/await isteği yine de sıralı kalabilir."

Promise.all ile düzeltmek — ve başarısızlık modu

İki isteği de, ikisini de beklemeden başlat:

export default async function ProjectPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
 
  // her iki istek de hemen ateşlenir, biri diğerini beklemez
  const projectPromise = getProject(id);
  const tasksPromise = getTasks(id);
 
  const [project, tasks] = await Promise.all([projectPromise, tasksPromise]);
  return <ProjectView project={project} tasks={tasks} />;
}

getProject(id)'i await olmadan çağırmak fetch'i başlatır ve hemen bir promise döner; fonksiyon onun ötesinde çalışmaya devam eder. Her iki istek de biri diğerini beklemeden Django'ya karşı aynı anda açıkta bekler. Toplam süre ikisinin toplamı değil, yavaş olanınkiyle sınırlıdır.

Tuzak şurada: Promise.all, içindeki promise'lerden biri reddedilir reddedilmez tamamı reddedilir. Görev listesi endpoint'i, proje henüz hiç görev içermediği için 404 dönerse, düzgün sonuçlanmış olan getProject de onunla birlikte çöpe gider ve sayfa tümüyle error.tsx'e düşer. Sayfada anlamlı bir şey göstermek için her parçanın zorunlu olduğu durumda bu doğru davranış. Bir parça isteğe bağlıysa yanlış davranış.

Başarısız olmasına izin verilen parçalar için Promise.allSettled

Diyelim ki panoda bir de "takım" bölümü var, ama birçok projeye henüz takım atanmamış ve DRF'in NotFound'ı bu durumda 404 dönüyor. Eksik bir takım, düzgün yüklenmiş proje ve görev verisini de aşağı çekmemeli:

const [projectResult, tasksResult, teamResult] = await Promise.allSettled([
  getProject(id),
  getTasks(id),
  getTeam(id),
]);
 
if (projectResult.status === "rejected") {
  notFound();
}
 
const project = projectResult.value;
const tasks = tasksResult.status === "fulfilled" ? tasksResult.value : [];
const team = teamResult.status === "fulfilled" ? teamResult.value : null;

allSettled hiçbir zaman reddedilmez — her giriş ya bir değer taşıyan başarılı sonuç, ya da bir sebep taşıyan reddedilmiş sonuç olarak döner, yani başarısız bir takım sorgusu hata sayfasına değil boş bir bölüme düşer. Bunu yalnızca gerçekten isteğe bağlı veri için kullan; her yerde kullanmak, Promise.all'ın bedavaya yakalayacağı hataları kendi elinle ad hoc biçimde yönetmen anlamına gelir.

Veri şeklini bozan DRF varsayılanı

Bir tutorial'dan data.results üzerinde .map() kopyalayanların takıldığı yer burası: DRF'in DEFAULT_PAGINATION_CLASS ve PAGE_SIZE ayarları kutudan çıktığı gibi ikisi de None. Sayfalama isteğe bağlı. pagination_class tanımlanmamış ve global bir varsayılan da yoksa, GET /api/tasks/ düz bir JSON dizisi döner — count/next/previous/results zarfı değil. Backend tarafında biri PageNumberPagination'ı açtığı an (genelde çok sonra, bir liste büyüyüp gerçekten sorun olmaya başladığında), const tasks = await getTasks(id) deyip ardından tasks.map(...) yapan her Server Component kırılır — çünkü tasks artık bir dizi değil, results alanlı bir nesnedir.

Yanıtı, sayfalanmış bir DRF tutorial'ının varsaydığı gibi değil, backend'in gerçekte yapılandırıldığı gibi tiple:

type PaginatedResponse<T> = {
  count: number;
  next: string | null;
  previous: string | null;
  results: T[];
};
 
async function getTasks(id: string, page = 1) {
  const res = await apiFetch(`/api/projects/${id}/tasks/?page=${page}`);
  const data: PaginatedResponse<Task> = await res.json();
  return data;
}

Query parametresinin adı (page_query_param üzerinden varsayılan olarak page) ve yanıt zarfı PageNumberPagination'dan geliyor; LimitOffsetPagination bunun yerine limit/offset kullanır ama aynı count/next/previous/results şeklini korur. Hangisini kullanıyorsan onu ?page=1'i sabit kodlamak yerine sayfa component'inin searchParams'ından geçir. Sayfa sayısı binlere tırmanmaya başlarsa offset tabanlı sayfalama, her yerde olduğu gibi yavaşlar — cursor alternatifini OFFSET sayfalama neden yavaşlar yazısında anlattım.

DRF'in 404'ünü exception değil veri olarak ele al

getProject, var olmayan bir projeye çarptığında DRF'in NotFound exception'ı, tek bir detail alanı "Not found." değerini taşıyan bir HTTP 404 üretir. res.ok'u kontrol edip Next'in notFound()'ını çağırmak sana Next'in kendi bulunamadı sayfasını verir. Bunun yerine genel bir Error fırlatmak error.tsx'e yönlendirir — orası beklenmedik durum için, "URL'deki id yok" için değil:

async function getProject(id: string) {
  const res = await apiFetch(`/api/projects/${id}/`);
  if (res.status === 404) {
    notFound();
  }
  if (!res.ok) {
    throw new Error(`Failed to load project ${id}`);
  }
  return res.json();
}

Aynı ayrım doğrulama hataları için de geçerli. DRF bunları alan adlarını anahtar olarak kullanarak döner — mesela "This field may not be blank." gibi mesajlardan oluşan bir dizi taşıyan title alanı — bu bir form handler'ı için anlamlı, error.tsx için anlamsız. Beklenen, yapılandırılmış hatalar return değerlerine ya da notFound()'a ait; throw'a yalnızca gerçekten beklenmedik olanlar ulaşmalı.

error.tsx 16.2'de ikinci bir kurtarma fonksiyonu kazandı

DRF backend'in kısa süreliğine erişilemez olur ve bir fetch throw ederse, error.tsx onu yakalar. Next 16.1'e kadar component sadece reset() alıyordu — bu, hata sınırının yerel durumunu temizler ve children'ı yeniden render eder, ama başarısız olan veri çekme işlemini yeniden çalıştırmaz. Backend hâlâ ayaktaysa aynı hatayı anında yeniden görürsün, ayakta değilse kafa karıştıran boş bir yanıp sönme yaşarsın.

Next 16.2, insanların genelde reset'ten beklediği şeyi yapan unstable_retry'yi ekledi: segmenti yeniden fetch edip yeniden render eder, sadece sınırı temizlemekle kalmaz.

"use client";
 
export default function Error({
  error,
  unstable_retry,
}: {
  error: Error & { digest?: string };
  unstable_retry: () => void;
}) {
  return (
    <div>
      <h2>Proje yüklenemedi.</h2>
      <button onClick={() => unstable_retry()}>Tekrar dene</button>
    </div>
  );
}

reset, fetch'i bilerek tekrarlamak istemediğin dar durum için hâlâ duruyor. İkisinden birine uzanmadan önce bilinmesi gereken bir şey daha: Server Component'lerde fırlatılan exception'lar için error.message, production'da genel bir mesajla değiştirilir; gerçek ayrıntı yalnızca sunucu loglarına karşı error.digest üzerinden erişilebilir. Bu, backend hata metninin client'a sızmasını önlemek için bilinçli bir tercih — DRF'in yapılandırılmış 4xx yanıtlarını fırlatılmış hata olarak yüzeye çıkmasına izin vermek yerine açıkça yakalamak için bir sebep daha.

Özetle desen

Django'ya giden bağımsız istekler art arda değil, birlikte başlamalı — her parça zorunluysa Promise.all, bazıları gerçekten isteğe bağlıysa Promise.allSettled. Bir liste endpoint'inin sayfalanmış olduğunu ya da olmadığını varsayma; DRF sayfalamayı kapalı gelir ve biri onu açtığı gün yanıtın şekli değişir. DRF'in 4xx yanıtlarını component'inin dallandığı veri gibi ele al — notFound(), bir return değeri — throw ile error.tsx'i kimsenin planlamadığı hatalara sakla; orada artık gerçekten yeniden deneyen fonksiyon unstable_retry.