Kurs JavaScript i React · Moduł 15: Wzorce i architektura

Feature Flags i A/B Testing w React - Kontrolowane wdrożenia

8 min czytania
W tej lekcji8

Nowy system nawigacyjny jest gotowy, ale nikt nie wie, jak zachowa się w prawdziwej misji. Jeśli wypuścisz go całej flocie i coś pójdzie nie tak, ratunkiem jest dopiero kolejny deploy. Dlatego na statku kosmicznym, zanim aktywujesz nowy system dla całej floty, testujesz go na jednym statku. Jeśli działa, wdrażasz go na reszcie. Jeśli nie, wyłączasz go jednym przełącznikiem. To właśnie Feature Flags: mechanizm pozwalający włączać i wyłączać funkcjonalności bez deploymentu nowego kodu.

Czym są Feature Flags?

Feature Flag (flaga funkcjonalności) to wartość boolean lub konfiguracja, która decyduje, czy dana funkcja jest dostępna. Najprostsza forma to zwykły obiekt z flagami i warunek w JSX:

1// Najprostsza forma - obiekt z flagami
2const featureFlags = {
3  newDashboard: true,
4  darkMode: true,
5  experimentalNav: false,
6  betaSearch: false,
7};
8
9// Użycie w komponencie
10function App() {
11  return (
12    <div>
13      {featureFlags.newDashboard
14        ? <NewDashboard />
15        : <OldDashboard />
16      }
17      {featureFlags.betaSearch && <BetaSearchBar />}
18    </div>
19  );
20}

Kod starego i nowego dashboardu leży obok siebie, a to, który zobaczy załoga, zależy od jednej wartości. Operator && pokazuje pasek wyszukiwania tylko przy włączonej fladze. Ten obiekt jest jednak wpisany w kod, więc zmiana flagi nadal wymaga deploymentu.

Feature Flag Provider

W prawdziwej aplikacji flagi przechowujemy w kontekście, żeby były dostępne wszędzie bez importowania obiektu. Do ich odczytu piszemy mały hook:

1const FeatureFlagContext = createContext({});
2
3function FeatureFlagProvider({ flags, children }) {
4  return (
5    <FeatureFlagContext.Provider value={flags}>
6      {children}
7    </FeatureFlagContext.Provider>
8  );
9}
10
11function useFeatureFlag(flagName) {
12  const flags = useContext(FeatureFlagContext);
13  return flags[flagName] ?? false;
14}

Dzięki operatorowi ?? nieznana flaga daje false zamiast undefined: literówka w nazwie po prostu wyłącza funkcję, a komponent zawsze dostaje wartość logiczną. Komponent pyta już tylko o jedną flagę po nazwie:

1// Użycie
2function NavigationBar() {
3  const showNewNav = useFeatureFlag('newNavigation');
4
5  return showNewNav ? <NewNavBar /> : <ClassicNavBar />;
6}

NavigationBar nie wie, skąd pochodzą flagi, zna tylko ich nazwy. Od React 19 zamiast <FeatureFlagContext.Provider value={flags}> możesz pisać krócej <FeatureFlagContext value={flags}>, a starszy zapis nadal działa.

Komponent Feature - deklaratywne flagi

Zamiast pisać if/else w każdym komponencie, możemy stworzyć deklaratywny komponent, który przyjmuje nazwę flagi, zawartość i opcjonalny fallback:

1function Feature({ name, children, fallback = null }) {
2  const isEnabled = useFeatureFlag(name);
3  return isEnabled ? children : fallback;
4}

Trzy linijki wystarczą, bo całą pracę wykonuje hook. Ten sam komponent zmieścisz nawet w jednej linii, jako funkcję strzałkową, która od razu zwraca wynik warunku:

1const Feature = ({ name, children, fallback = null }) => useFeatureFlag(name) ? children : fallback;

To dokładnie ten sam komponent: hook jest wywoływany przy każdym renderze i zawsze w tym samym miejscu, więc reguły hooków są zachowane. Wybierz zapis, który jest czytelniejszy dla Twojego zespołu. Teraz centrum dowodzenia czyta się jak lista kontrolna: ten panel pokaż przy włączonej fladze, a tamten zastąp starszą wersją:

1// Użycie - czytelne i deklaratywne
2function MissionControl() {
3  return (
4    <div>
5      <h1>Centrum dowodzenia</h1>
6
7      <Feature name="advancedRadar">
8        <AdvancedRadarPanel />
9      </Feature>
10
11      <Feature
12        name="newStatusBar"
13        fallback={<LegacyStatusBar />}
14      >
15        <ModernStatusBar />
16      </Feature>
17
18      <Feature name="experimentalWarpDrive">
19        <WarpDriveControls />
20      </Feature>
21    </div>
22  );
23}

Gdy flaga jest wyłączona, a fallback nie podano, Feature zwraca null i panel po prostu znika. Zapis <Feature name="newDashboard" fallback={<OldDashboard />}> z nową wersją jako dzieckiem to najczęstszy sposób przełączania między starym i nowym widokiem.

Przełączniki w locie - flagi w stanie

Provider dostaje flagi przez props, więc można trzymać je w stanie. Wtedy panel administratora przełącza funkcje w trakcie działania aplikacji, a każda flaga dostaje checkbox:

1function FlagAdmin({ flags, setFlags }) {
2  const toggle = (name) =>
3    setFlags(prev => ({ ...prev, [name]: !prev[name] }));
4
5  return (
6    <div className="flag-admin">
7      {Object.keys(flags).map(name => (
8        <label key={name}>
9          <input type="checkbox" checked={flags[name]} onChange={() => toggle(name)} />
10          {name}
11        </label>
12      ))}
13    </div>
14  );
15}

toggle kopiuje obiekt zamiast go modyfikować, bo React porównuje stan po referencji i zmiany w tym samym obiekcie by nie zauważył. Pozostaje połączyć stan, provider i panel w komponencie głównym:

1function App() {
2  const [flags, setFlags] = useState({ newDashboard: true, advancedRadar: false });
3
4  return (
5    <FeatureFlagProvider flags={flags}>
6      <FlagAdmin flags={flags} setFlags={setFlags} />
7      <Feature name="newDashboard" fallback={<OldDashboard />}>
8        <NewDashboard />
9      </Feature>
10    </FeatureFlagProvider>
11  );
12}

Prześledźmy drogę flagi po kliknięciu checkboxa, bo na niej opiera się cały wzorzec:

  1. Flagi siedzą w stanie App: FlagAdmin wywołuje setFlags i stan dostaje nowy obiekt.
  2. FeatureFlagProvider otrzymuje ten obiekt przez props i przekazuje go do kontekstu jako value.
  3. useFeatureFlag('newDashboard') czyta z kontekstu wartość jednej flagi.
  4. Feature na tej podstawie renderuje children albo fallback.
  5. Ekran pokazuje nowy albo stary dashboard.

Kod dashboardów nie zmienił się ani trochę: o tym, co widzi załoga, decyduje wyłącznie stan na szczycie drzewa.

A/B Testing - warianty dla różnych użytkowników

A/B Testing to rozszerzenie feature flags, w którym różni użytkownicy widzą różne wersje interfejsu, a Ty mierzysz, która działa lepiej. Hook losuje wariant raz i zapamiętuje go w localStorage:

1function useABTest(testName, variants) {
2  const [variant, setVariant] = useState(null);
3
4  useEffect(() => {
5    // Sprawdź, czy użytkownik już ma przypisany wariant
6    const stored = localStorage.getItem(`ab_${testName}`);
7    if (stored && variants.includes(stored)) {
8      setVariant(stored);
9    } else {
10      // Losowo przypisz wariant
11      const randomVariant = variants[
12        Math.floor(Math.random() * variants.length)
13      ];
14      localStorage.setItem(`ab_${testName}`, randomVariant);
15      setVariant(randomVariant);
16    }
17  }, [testName, variants]);
18
19  return variant;
20}

Dzięki zapisowi ten sam użytkownik po odświeżeniu zobaczy tę samą wersję. Kod siedzi w useEffect, bo localStorage istnieje tylko w przeglądarce. Tak wygląda użycie:

1// Użycie: tablica wariantów w stałej poza komponentem
2const CTA_VARIANTS = ['control', 'variant_a', 'variant_b'];
3
4function LandingPage() {
5  const variant = useABTest('landing_cta', CTA_VARIANTS);
6
7  if (variant === 'control') {
8    return <ClassicHero ctaText="Rozpocznij misję" />;
9  }
10  if (variant === 'variant_a') {
11    return <ClassicHero ctaText="Dołącz do floty!" />;
12  }
13  return <ModernHero ctaText="Startuj teraz" />;
14}

Uwaga na pierwszy render: variant to jeszcze null, więc LandingPage na moment pokaże ModernHero, zanim efekt wylosuje wariant. W prawdziwym kodzie obsłuż null, na przykład zwracając szkielet strony. Tablica wariantów leży w stałej CTA_VARIANTS poza komponentem, bo nowa tablica przy każdym renderze uruchamiałaby efekt ponownie.

Percentage Rollout - stopniowe wdrożenia

Zamiast włączać flagę dla wszystkich naraz, można wdrażać stopniowo, np. 10% użytkowników, potem 50%, potem 100%. Każda przeglądarka losuje raz, czy trafia do grupy z nową funkcją:

1function usePercentageRollout(featureName, percentage) {
2  const [isEnabled, setIsEnabled] = useState(false);
3
4  useEffect(() => {
5    // Losowanie raz na przeglądarkę, wynik zapisany w localStorage
6    const stored = localStorage.getItem(`rollout_${featureName}`);
7    if (stored !== null) {
8      setIsEnabled(stored === 'true');
9    } else {
10      const random = Math.random() * 100;
11      const enabled = random < percentage;
12      localStorage.setItem(`rollout_${featureName}`, String(enabled));
13      setIsEnabled(enabled);
14    }
15  }, [featureName, percentage]);
16
17  return isEnabled;
18}
19
20// Użycie
21function Dashboard() {
22  const showNewChart = usePercentageRollout('new_chart_v2', 30); // 30% użytkowników
23
24  return (
25    <div>
26      {showNewChart ? <NewInteractiveChart /> : <LegacyChart />}
27    </div>
28  );
29}

Przy progu 30 nowy wykres dostanie około 30% przeglądarek, a zapisany wynik nie zmienia się przy odświeżeniu. Profesjonalne systemy liczą zamiast tego deterministyczny hash z identyfikatora użytkownika i nazwy flagi: wynik jest taki sam na każdym urządzeniu, a podniesienie progu nie odbiera funkcji nikomu, kto już ją ma.

Dynamiczne flagi z serwera

W produkcji flagi często przychodzą z serwera (LaunchDarkly, Flagsmith, Unleash). Dopiero wtedy spełniamy obietnicę z początku lekcji: zmieniasz flagę w panelu i niczego nie wdrażasz:

1function useRemoteFlags() {
2  const [flags, setFlags] = useState({});
3  const [loading, setLoading] = useState(true);
4
5  useEffect(() => {
6    fetch('/api/feature-flags')
7      .then(res => res.json())
8      .then(data => {
9        setFlags(data);
10        setLoading(false);
11      })
12      .catch(() => setLoading(false));
13  }, []);
14
15  return { flags, loading };
16}
17
18// Użycie z FeatureFlagProvider
19function App() {
20  const { flags, loading } = useRemoteFlags();
21
22  if (loading) return <Spinner />;
23
24  return (
25    <FeatureFlagProvider flags={flags}>
26      <Dashboard />
27    </FeatureFlagProvider>
28  );
29}

Hook pobiera flagi raz, a FeatureFlagProvider rozsyła je po drzewie. Feature i useFeatureFlag nie zmieniły się wcale, zmieniło się tylko źródło danych. Gdy serwer nie odpowie, .catch kończy ładowanie z pustym obiektem, czyli z wyłączonymi eksperymentami.

Kiedy używać Feature Flags?

SytuacjaTyp flagi
Nowa funkcja w trakcie developmentuBoolean flag (true/false)
Testowanie różnych wersji UIA/B test (warianty)
Stopniowe wdrożenie dla użytkownikówPercentage rollout
Szybkie wyłączenie problematycznej funkcjiKill switch
Różne funkcje dla różnych planówUser-based flag

Moja rada: każda flaga to dług. Gdy funkcja działa już u wszystkich, usuń flagę i starą gałąź kodu, zanim zrobi się z nich labirynt warunków. Feature Flags to jak centralny panel kontroli statku: włączasz dowolny system jednym przyciskiem, testujesz nowy silnik na jednym statku przed całą flotą i cofasz zmiany, gdy coś pójdzie nie tak, bez lądowania na stacji i wymiany części. W następnej lekcji moduły statku zaczną rozmawiać ze sobą przez zdarzenia.

Pamiętaj: flaga oddziela wdrożenie kodu od włączenia funkcji.

Kod do tej lekcji: App.jsx
1import React, { createContext, useContext, useState, useEffect } from 'react';
2
3// === KONTEKST FEATURE FLAGS ===
4const FeatureFlagContext = createContext({});
5
6function FeatureFlagProvider({ flags, children }) {
7  return (
8    <FeatureFlagContext.Provider value={flags}>
9      {children}
10    </FeatureFlagContext.Provider>
11  );
12}
13
14function useFeatureFlag(flagName) {
15  const flags = useContext(FeatureFlagContext);
16  return flags[flagName] ?? false;
17}
18
19// === DEKLARATYWNY KOMPONENT <Feature> ===
20function Feature({ name, children, fallback = null }) {
21  const isEnabled = useFeatureFlag(name);
22  return isEnabled ? children : fallback;
23}
24
25// === HOOK TESTU A/B ===
26function useABTest(testName, variants) {
27  const [variant, setVariant] = useState(null);
28  useEffect(() => {
29    const key = 'ab_' + testName;
30    const stored = localStorage.getItem(key);
31    if (stored && variants.includes(stored)) {
32      setVariant(stored);
33    } else {
34      const picked = variants[Math.floor(Math.random() * variants.length)];
35      localStorage.setItem(key, picked);
36      setVariant(picked);
37    }
38  }, [testName, variants]);
39  return variant;
40}
41
42// === KOMPONENTY UI ===
43function ClassicDashboard() {
44  return (
45    <div className="dashboard classic">
46      <h3>Klasyczny dashboard</h3>
47      <p>Standardowy przegląd misji z podstawowymi statystykami.</p>
48      <div className="stats-row">
49        <div className="stat">Paliwo: 87%</div>
50        <div className="stat">Prędkość: 12 tys.</div>
51        <div className="stat">Załoga: 6</div>
52      </div>
53    </div>
54  );
55}
56
57function ModernDashboard() {
58  return (
59    <div className="dashboard modern">
60      <h3>Nowoczesny dashboard (NOWOŚĆ!)</h3>
61      <p>Rozszerzony przegląd misji z danymi na żywo.</p>
62      <div className="stats-row">
63        <div className="stat glow">Paliwo: 87%</div>
64        <div className="stat glow">Prędkość: 12 tys. km/s</div>
65        <div className="stat glow">Załoga: 6 aktywnych</div>
66        <div className="stat glow">Osłony: 94%</div>
67      </div>
68    </div>
69  );
70}
71
72function RadarV1() {
73  return <div className="radar v1"><h4>Radar v1</h4><p>Zasięg podstawowy: 100 l.ś.</p></div>;
74}
75
76function RadarV2() {
77  return <div className="radar v2"><h4>Radar v2 (Beta)</h4><p>Zasięg rozszerzony: 500 l.ś. + wykrywanie anomalii</p></div>;
78}
79
80function CTAButton({ text, color }) {
81  return <button className="cta-btn" style={{ background: color }}>{text}</button>;
82}
83
84// === PANEL ADMINISTRATORA FLAG ===
85function FlagAdmin({ flags, setFlags }) {
86  return (
87    <div className="admin-panel">
88      <h3>Panel administratora flag</h3>
89      {Object.entries(flags).map(([key, val]) => (
90        <label key={key} className="flag-toggle">
91          <input type="checkbox" checked={val} onChange={() => setFlags(p => ({ ...p, [key]: !p[key] }))} />
92          <span className={"flag-name " + (val ? 'on' : 'off')}>{key}: {val ? 'WŁĄCZONA' : 'WYŁĄCZONA'}</span>
93        </label>
94      ))}
95    </div>
96  );
97}
98
99// Stałe poza komponentem: ta sama tablica przy każdym renderze, więc efekt testu A/B nie startuje ponownie
100const CTA_VARIANTS = ['blue', 'green', 'orange'];
101const ctaColors = { blue: '#1565c0', green: '#2e7d32', orange: '#e65100' };
102const variantLabels = { blue: 'niebieski', green: 'zielony', orange: 'pomarańczowy' };
103
104// === GŁÓWNY KOMPONENT ===
105export default function App() {
106  const [flags, setFlags] = useState({
107    modernDashboard: true,
108    advancedRadar: false,
109    experimentalWarp: false,
110  });
111
112  const ctaVariant = useABTest('cta_color', CTA_VARIANTS);
113
114  return (
115    <FeatureFlagProvider flags={flags}>
116      <div className="app">
117        <h1>Feature Flags i A/B Testing</h1>
118        <p className="subtitle">Przełączaj funkcje i sprawdzaj warianty testu A/B</p>
119
120        <FlagAdmin flags={flags} setFlags={setFlags} />
121
122        <Feature name="modernDashboard" fallback={<ClassicDashboard />}>
123          <ModernDashboard />
124        </Feature>
125
126        <Feature name="advancedRadar" fallback={<RadarV1 />}>
127          <RadarV2 />
128        </Feature>
129
130        <Feature name="experimentalWarp">
131          <div className="warp-panel">
132            <h3>Eksperymentalny napęd warp</h3>
133            <p>UWAGA: ta funkcja jest w fazie testów alfa!</p>
134            <button className="btn btn-danger">Włącz warp</button>
135          </div>
136        </Feature>
137
138        <div className="section">
139          <h3>Test A/B: przycisk CTA (wariant: {variantLabels[ctaVariant]})</h3>
140          {ctaVariant && <CTAButton text="Rozpocznij misję" color={ctaColors[ctaVariant]} />}
141          <p className="meta">Twój wariant jest zapisany w localStorage</p>
142        </div>
143      </div>
144    </FeatureFlagProvider>
145  );
146}

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Jaki jest główny cel Feature Flags w aplikacji React?

Zadania praktyczne w grze

  • Edytor kodu

    Zbuduj system flag funkcji na kontekście. ___BLANK1___: FeatureFlagProvider podaje kontekstowi flagi, które dostał w propsie. ___BLANK2___: useFeatureFlag odczytuje flagi z FeatureFlagContext hookiem useContext (nieznana flaga daje false dzięki ?? false). ___BLANK3___: Feature zwraca children, gdy flaga jest włączona, a w przeciwnym razie fallback. ___BLANK4___: w panelu FlagAdmin kliknięcie checkboxa odwraca wartość tej flagi w nowym obiekcie flag. App trzyma flagi w stanie, więc kliknięcie od razu przełącza stary i nowy dashboard.

  • Klikanie w kolejności

    Kliknij w kolejności elementy jednolinijkowego komponentu Feature: włączona flaga pokazuje children, wyłączona fallback.

Przydatne artykuły