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
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 stateless | Rozwiązanie | Złożoność |
|---|---|---|
| Serwer nie pamięta danych | Cache (Redis) | + 1 serwis, + TTL, + invalidation |
| Przeglądarka musi zarządzać stanem | Redux / Zustand / React Query | + 2 000 linii kodu |
| Serwer nie wie, co user widzi | WebSocket (Socket.io) | + 1 protokół, + infrastructure |
| Formularz traci stan przy błędzie | Controlled components + error state | + boilerplate |
| Real-time wymaga osobnej warstwy | Separate 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
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
endZero 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 + OrderSchemaLiveView (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.jsLiveView:
# 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
| Warstwa | React + Next.js + API | Phoenix LiveView |
|---|---|---|
| Frontend framework | React | Brak (HTML z serwera) |
| State management | Redux / Zustand | assign() w procesie |
| Data fetching | React Query / SWR | mount() raz |
| Formularz | React Hook Form | Changeset + phx-change |
| Routing | React Router / Next Router | LiveView router |
| WebSocket | Socket.io + Redis | Phoenix PubSub (wbudowany) |
| SSR / SEO | Next.js SSR | Domyślnie server-rendered |
| Walidacja | Zod (client) + Joi (server) | Changeset (jedno miejsce) |
| Autentykacja | NextAuth / Clerk | mix phx.gen.auth |
| Pliki per feature | 5-8 | 1-2 |
| Linie kodu per feature | 300-500 | 60-120 |
| Zewnętrzne zależności | 15-30 npm | 3-5 hex |
Kiedy stateful web nie pasuje
LiveView nie jest idealny do wszystkiego:
| Scenariusz | LiveView | React/SPA |
|---|---|---|
| Panel admin / ERP / CRM | ✓ | Overkill |
| Dashboard z real-time | ✓ | Wymaga 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 marketingowa | Overkill | ✓ (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:
| Rola | React + API | LiveView |
|---|---|---|
| Frontend developer (React) | 1-2 osoby | 0 |
| Backend developer (API) | 1-2 osoby | 0 |
| Fullstack developer (Elixir + LiveView) | 0 | 2-3 osoby |
| DevOps (Redis, WS infra) | 0.5 osoby | 0 |
| Łączny zespół | 3-5 osób | 2-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ładnik | React + Next.js + API | Phoenix LiveView |
|---|---|---|
| Setup projektu | 2 tygodnie (2 repozytoria) | 3 dni (1 repozytorium) |
| 20 widoków × czas/widok | 20 × 4 dni = 80 dni | 20 × 1.5 dnia = 30 dni |
| State management | 2 tygodnie | 0 (wbudowany) |
| Real-time (WebSocket) | 2 tygodnie | 0 (wbudowany) |
| Testy (E2E + unit) | 3 tygodnie | 1.5 tygodnia |
| Łączny czas | ~22 tygodnie | ~8 tygodni |
| Koszt (przy 30k/tydzień) | 660 000 PLN | 240 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.