Stateful Web - dlaczego Twój system nie musi udawać, że nie pamięta użytkownika

Każdy system webowy, który zbudowałeś lub z którego korzystasz, udaje amnezję. Użytkownik klika „pokaż zamówienia" - serwer odpytuje bazę, renderuje HTML, wysyła, zapomina. Użytkownik klika „sortuj po dacie" - serwer ponownie odpytuje bazę, ponownie renderuje, ponownie wysyła, ponownie zapomina.

Między requestami serwer nie wie, kim jest użytkownik, co robił sekundę temu, ani jakie dane miał na ekranie. Każdy request zaczyna od zera. To jest stateless web - model, który wymyślono w 1995 roku i który od tamtego czasu nie kwestionujemy.

Phoenix LiveView mówi: a co jeśli serwer pamięta?

Stateless - jak działają dzisiejsze aplikacje

Diagram sekwencji: stateless request flow — dwa kliknięcia, dwa pełne round-tripy do bazy

Dwa kliknięcia = dwa pełne round-tripy do bazy, serializacja JSON, deserializacja, re-render w przeglądarce. Serwer nie wie, że minutę temu wysłał te same 200 zamówień - bo zapomniał.

Żeby to obejść, budujemy coraz bardziej skomplikowane warstwy:

Problem statelessRozwiązanieZłożoność
Serwer nie pamięta danychCache (Redis)+ 1 serwis, + TTL, + invalidation
Przeglądarka musi zarządzać stanemRedux / Zustand / React Query+ 2 000 linii kodu
Serwer nie wie, co user widziWebSocket (Socket.io)+ 1 protokół, + infrastructure
Formularz traci stan przy błędzieControlled components + error state+ boilerplate
Real-time wymaga osobnej warstwySeparate event system+ Kafka/Redis Pub/Sub

Każda warstwa to odpowiedź na fundamentalne ograniczenie modelu stateless. Zamiast naprawiać model, doklejamy obejścia.

Stateful - jak działa Phoenix LiveView

Diagram sekwencji: stateful LiveView — sortowanie w pamięci, zero zapytań do bazy

Drugie kliknięcie: zero zapytań do bazy, zero serializacji JSON, zero re-renderu całej strony. LiveView sortuje dane w pamięci procesu, generuje diff HTML i wysyła tylko zmienione fragmenty przez WebSocket.

Jak to wygląda w kodzie

defmodule MyAppWeb.OrdersLive do
  use MyAppWeb, :live_view

  @impl true
  def mount(_params, _session, socket) do
    # Pobierz dane RAZ - zostają w pamięci procesu
    orders = Orders.list_for_company(socket.assigns.current_user.company_id)

    socket =
      socket
      |> assign(orders: orders, sort_by: :inserted_at, sort_dir: :desc)
      |> assign(filter: "all")

    {:ok, socket}
  end

  @impl true
  def handle_event("sort", %{"field" => field}, socket) do
    field = String.to_existing_atom(field)

    {sort_by, sort_dir} =
      if socket.assigns.sort_by == field do
        {field, toggle_dir(socket.assigns.sort_dir)}
      else
        {field, :asc}
      end

    # Sortowanie w pamięci - instant, zero DB
    orders = sort_orders(socket.assigns.orders, sort_by, sort_dir)

    {:noreply, assign(socket, orders: orders, sort_by: sort_by, sort_dir: sort_dir)}
  end

  @impl true
  def handle_event("filter", %{"status" => status}, socket) do
    # Filtrowanie w pamięci - instant
    filtered =
      if status == "all" do
        socket.assigns.orders
      else
        Enum.filter(socket.assigns.orders, &(&1.status == String.to_existing_atom(status)))
      end

    {:noreply, assign(socket, filtered_orders: filtered, filter: status)}
  end

  @impl true
  def handle_event("search", %{"query" => query}, socket) do
    # Wyszukiwanie w pamięci - instant, zero debounce potrzebny
    results =
      socket.assigns.orders
      |> Enum.filter(fn o ->
        String.contains?(String.downcase(o.number), String.downcase(query)) ||
        String.contains?(String.downcase(o.customer_name), String.downcase(query))
      end)

    {:noreply, assign(socket, filtered_orders: results)}
  end
end

Zero Redux. Zero React Query. Zero useState, useEffect, useMemo. Zero JSON serializacji. Logika jest na serwerze, dane są w pamięci procesu, HTML wysyłany jako diff.

Co pamięta proces LiveView

Każdy użytkownik podłączony do LiveView ma własny proces BEAM na serwerze. Ten proces pamięta:

# Stan procesu LiveView dla jednego użytkownika:
%{
  # Dane biznesowe (z bazy, wczytane raz)
  orders: [%Order{}, %Order{}, ...],    # 200 zamówień
  customers: [%Customer{}, ...],          # 50 klientów

  # Stan interfejsu
  sort_by: :inserted_at,
  sort_dir: :desc,
  filter: "all",
  search_query: "",
  selected_order: nil,
  modal_open: false,
  current_page: 1,

  # Dane użytkownika
  current_user: %User{id: 42, role: :sales},
  company_id: "acme-corp",

  # Subskrypcje PubSub
  # (proces automatycznie dostaje aktualizacje z innych procesów)
}

Pamięć jednego procesu LiveView: ~50-200 KB. 100 użytkowników online: ~5-20 MB. To nic dla serwera z 32 GB RAM.

Stateful vs Stateless - porównanie codzienne

Formularz z walidacją

React + API (stateless):

1. User wpisuje dane w formularzu
2. React trzyma stan w useState (client-side)
3. User klika "Zapisz"
4. React serializuje do JSON
5. HTTP POST do API
6. Serwer deserializuje JSON
7. Serwer waliduje dane
8. Jeśli błąd → JSON z błędami → React parsuje → wyświetla
9. Jeśli OK → JSON z odpowiedzią → React aktualizuje stan
10. Cały ten ping-pong przy KAŻDYM submit

Czas: 200-500 ms per submit
Pliki: FormComponent.jsx + useForm hook + api.js + OrderController + OrderSchema

LiveView (stateful):

1. User wpisuje dane → phx-change="validate" → server waliduje na żywo
2. Changeset z błędami → diff HTML → błędy przy polach natychmiast
3. User klika "Zapisz" → phx-submit="save" → zapis do bazy
4. Jeśli błąd → changeset → diff z błędami
5. Jeśli OK → redirect lub flash message

Czas: < 50 ms per walidacja (WebSocket, brak HTTP overhead)
Pliki: OrderFormLive.ex (jeden plik)

Tabela z sortowaniem, filtrowaniem i wyszukiwaniem

React + API:

// React - każda interakcja = request do API
function OrderTable() {
  const [orders, setOrders] = useState([]);
  const [sortBy, setSortBy] = useState('date');
  const [sortDir, setSortDir] = useState('desc');
  const [filter, setFilter] = useState('all');
  const [search, setSearch] = useState('');
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const [page, setPage] = useState(1);

  useEffect(() => {
    setLoading(true);
    fetch(`/api/orders?sort=${sortBy}&dir=${sortDir}&filter=${filter}&search=${search}&page=${page}`)
      .then(r => r.json())
      .then(data => { setOrders(data.orders); setLoading(false); })
      .catch(e => { setError(e); setLoading(false); });
  }, [sortBy, sortDir, filter, search, page]);

  // + loading spinner
  // + error handling
  // + debounce na search
  // + optimistic updates
  // + cache invalidation
  // ...150 linii boilerplate
}

LiveView:

# LiveView - interakcje w pamięci, zero requestów
def handle_event("sort", %{"field" => field}, socket) do
  orders = sort(socket.assigns.orders, field)
  {:noreply, assign(socket, orders: orders)}
end

def handle_event("filter", %{"status" => status}, socket) do
  orders = filter(socket.assigns.all_orders, status)
  {:noreply, assign(socket, orders: orders)}
end

def handle_event("search", %{"query" => q}, socket) do
  orders = search(socket.assigns.all_orders, q)
  {:noreply, assign(socket, orders: orders)}
end
# 20 linii. Zero loading state. Zero error handling HTTP.
# Zero debounce (bo nie ma requestu HTTP do zrobienia).

Real-time aktualizacje

React + Socket.io:

Infrastruktura:
├── React app
├── Express API server
├── Socket.io server (osobny lub ten sam)
├── Redis Pub/Sub (do skalowania WS)
├── Event emitter / message queue
└── State synchronization logic

Pliki: WebSocketProvider.jsx + useWebSocket.js + socket-server.js + redis-adapter.js

LiveView:

# Subskrybuj kanał PubSub - 1 linia
Phoenix.PubSub.subscribe(MyApp.PubSub, "orders:#{company_id}")

# Obsłuż aktualizację - 4 linie
def handle_info({:order_created, order}, socket) do
  orders = [order | socket.assigns.orders]
  {:noreply, assign(socket, orders: orders)}
end

# Zero dodatkowej infrastruktury. Zero Redisa. Zero Socket.io.

Porównanie stacków

WarstwaReact + Next.js + APIPhoenix LiveView
Frontend frameworkReactBrak (HTML z serwera)
State managementRedux / Zustandassign() w procesie
Data fetchingReact Query / SWRmount() raz
FormularzReact Hook FormChangeset + phx-change
RoutingReact Router / Next RouterLiveView router
WebSocketSocket.io + RedisPhoenix PubSub (wbudowany)
SSR / SEONext.js SSRDomyślnie server-rendered
WalidacjaZod (client) + Joi (server)Changeset (jedno miejsce)
AutentykacjaNextAuth / Clerkmix phx.gen.auth
Pliki per feature5-81-2
Linie kodu per feature300-50060-120
Zewnętrzne zależności15-30 npm3-5 hex

Kiedy stateful web nie pasuje

LiveView nie jest idealny do wszystkiego:

ScenariuszLiveViewReact/SPA
Panel admin / ERP / CRMOverkill
Dashboard z real-timeWymaga Socket.io
Formularz wielokrokowy
Edytor graficzny (Figma-like)✓ (Canvas API)
Gra w przeglądarce (60 FPS)✓ (WebGL)
Aplikacja offline-first✓ (Service Worker)
Aplikacja mobilna (PWA)Częściowo
Strona marketingowaOverkill✓ (lub static)

80% systemów biznesowych to panele, formularze, tabele, dashboardy - dokładnie to, w czym LiveView jest najlepszy.

Koszt zespołu

Stateless web wymaga dwóch specjalizacji: frontend (React) i backend (API). Stateful web wymaga jednej:

RolaReact + APILiveView
Frontend developer (React)1-2 osoby0
Backend developer (API)1-2 osoby0
Fullstack developer (Elixir + LiveView)02-3 osoby
DevOps (Redis, WS infra)0.5 osoby0
Łączny zespół3-5 osób2-3 osoby

Przy stawce 20 000 PLN/miesiąc per developer: 20 000 - 40 000 PLN/miesiąc mniej na zespole.

Ale jest coś ważniejszego niż pieniądze: komunikacja. W modelu React + API, frontend i backend muszą uzgadniać kontrakty API, synchronizować zmiany, rozwiązywać konflikty wersji. W LiveView jeden developer widzi cały feature - od bazy po UI. Zero „to bug frontendowy czy backendowy?".

Co to oznacza dla budżetu projektu

System CRM z 20 widokami (lista klientów, szczegóły, zamówienia, raporty, ustawienia...):

SkładnikReact + Next.js + APIPhoenix LiveView
Setup projektu2 tygodnie (2 repozytoria)3 dni (1 repozytorium)
20 widoków × czas/widok20 × 4 dni = 80 dni20 × 1.5 dnia = 30 dni
State management2 tygodnie0 (wbudowany)
Real-time (WebSocket)2 tygodnie0 (wbudowany)
Testy (E2E + unit)3 tygodnie1.5 tygodnia
Łączny czas~22 tygodnie~8 tygodni
Koszt (przy 30k/tydzień)660 000 PLN240 000 PLN

420 000 PLN różnicy. Nie dlatego, że Elixir jest magiczny - dlatego, że stateful web eliminuje warstwy, które stateless web musi budować od zera.


Chcesz zobaczyć, jak LiveView uprości Twój kolejny projekt? Porozmawiajmy - pokażemy prototyp w kilka dni, nie tygodni.