Kurs JavaScript i React · Moduł 10: Ekosystem i przyszłość React

React 19 - Nowy sposób przekazywania ref (bez forwardRef)

5 min czytania
W tej lekcji6

Panel nawigacyjny chce ustawić fokus na polu współrzędnych ukrytym w komponencie potomnym. ref daje właśnie taki bezpośredni dostęp do elementu DOM, ale przypięty do własnego komponentu przez lata nie docierał do środka: React traktował go specjalnie i nie przekazywał w propsach. Trzeba było owijać komponent w forwardRef, jak w dodatkowy przekaźnik sygnału. W React 19 ta długo oczekiwana zmiana wreszcie nadeszła, podobnie jak nowe systemy komunikacji na statkach eliminują skomplikowane przekaźniki.

Stary sposób z forwardRef (React 18 i wcześniej)

W poprzednich wersjach Reacta, żeby przekazać ref do komponentu potomnego, trzeba było użyć forwardRef. Funkcja komponentu dostawała ref jako drugi argument, obok propsów:

1import { forwardRef, useRef } from 'react';
2
3// Stary sposób - wymaga forwardRef
4const SpaceshipButton = forwardRef((props, ref) => {
5  return (
6    <button ref={ref} className="spaceship-btn" {...props}>
7      {props.children}
8    </button>
9  );
10});
11
12// Użycie
13function MissionControl() {
14  const buttonRef = useRef(null);
15
16  const focusButton = () => {
17    buttonRef.current?.focus();
18  };
19
20  return (
21    <div>
22      <SpaceshipButton ref={buttonRef}>
23        Uruchom silniki
24      </SpaceshipButton>
25      <button onClick={focusButton}>Fokus na przycisk</button>
26    </div>
27  );
28}

MissionControl tworzy buttonRef przez useRef i po kliknięciu wywołuje focus() na prawdziwym przycisku. Działa, ale komponent ma nietypową sygnaturę (props, ref) i dodatkowe opakowanie, o którym trzeba pamiętać przy każdym takim komponencie.

Nowy sposób w React 19 - ref jako prop

W React 19 ref jest zwykłym propem komponentu funkcyjnego. Wyciągasz go z propsów jak każdą inną wartość:

1import { useRef } from 'react';
2
3// Nowy sposób w React 19 - ref to po prostu prop!
4function SpaceshipButton({ ref, children, ...props }) {
5  return (
6    <button ref={ref} className="spaceship-btn" {...props}>
7      {children}
8    </button>
9  );
10}
11
12// Użycie - dokładnie tak samo!
13function MissionControl() {
14  const buttonRef = useRef(null);
15
16  const focusButton = () => {
17    buttonRef.current?.focus();
18  };
19
20  return (
21    <div>
22      <SpaceshipButton ref={buttonRef}>
23        Uruchom silniki
24      </SpaceshipButton>
25      <button onClick={focusButton}>Fokus na przycisk</button>
26    </div>
27  );
28}

Zmieniła się tylko definicja: znika forwardRef, a ref trafia do destrukturyzacji obok children. Użycie w MissionControl jest dokładnie takie samo. Zasada dotyczy komponentów funkcyjnych, bo w klasowych ref nadal wskazuje instancję i nie trafia do propsów.

Praktyczne przykłady

1. Input z auto-focus

Formularz współrzędnych pokazuje, jak ref ułatwia nawigację klawiaturą. Każdy CoordinateInput przekazuje ref prosto do elementu input:

1// React 19 - prosty komponent inputa
2function CoordinateInput({ ref, label, ...props }) {
3  return (
4    <div className="coordinate-field">
5      <label>{label}</label>
6      <input ref={ref} {...props} />
7    </div>
8  );
9}
10
11function NavigationPanel() {
12  const latitudeRef = useRef(null);
13  const longitudeRef = useRef(null);
14
15  useEffect(() => {
16    // Auto-focus na pierwszy input
17    latitudeRef.current?.focus();
18  }, []);
19
20  const handleLatitudeEnter = (e) => {
21    if (e.key === 'Enter') {
22      longitudeRef.current?.focus();
23    }
24  };
25
26  return (
27    <form className="nav-panel">
28      <h2>Wprowadź współrzędne docelowe</h2>
29
30      <CoordinateInput
31        ref={latitudeRef}
32        label="Szerokość galaktyczna"
33        onKeyDown={handleLatitudeEnter}
34      />
35
36      <CoordinateInput
37        ref={longitudeRef}
38        label="Długość galaktyczna"
39      />
40
41      <button type="submit">Ustaw kurs</button>
42    </form>
43  );
44}

Po zamontowaniu efekt ustawia fokus na polu szerokości, a Enter przenosi go do długości. CoordinateInput pozostaje zwykłą funkcją bez opakowania, a pozostałe propsy, jak onKeyDown, trafiają do inputa przez ...props.

useImperativeHandle - nadal użyteczny

Czasem nie chcesz oddawać rodzicowi całego elementu DOM, tylko wybrane polecenia. useImperativeHandle nadal działa, ale teraz bez forwardRef: podajesz mu ref odebrany z propsów:

1import { useImperativeHandle, useRef } from 'react';
2
3function VideoPlayer({ ref, src }) {
4  const videoRef = useRef(null);
5
6  // Eksponuj tylko wybrane metody
7  useImperativeHandle(ref, () => ({
8    play: () => videoRef.current?.play(),
9    pause: () => videoRef.current?.pause(),
10    seek: (time) => {
11      if (videoRef.current) {
12        videoRef.current.currentTime = time;
13      }
14    },
15    getCurrentTime: () => videoRef.current?.currentTime || 0,
16  }), []);
17
18  return (
19    <video ref={videoRef} src={src} className="space-video">
20      Twoja przeglądarka nie obsługuje wideo.
21    </video>
22  );
23}
24
25function MediaController() {
26  const playerRef = useRef(null);
27
28  return (
29    <div>
30      <VideoPlayer ref={playerRef} src="/space-documentary.mp4" />
31
32      <div className="controls">
33        <button onClick={() => playerRef.current?.play()}>
34          Odtwórz
35        </button>
36        <button onClick={() => playerRef.current?.pause()}>
37          Pauza
38        </button>
39        <button onClick={() => playerRef.current?.seek(0)}>
40          Od początku
41        </button>
42      </div>
43    </div>
44  );
45}

Rodzic widzi tylko play, pause, seek i getCurrentTime, a nie cały element video. To bezpieczniejsze API, bo nikt z zewnątrz nie zmieni przypadkiem atrybutów odtwarzacza.

Migracja z forwardRef

Jeśli masz kod z forwardRef, migracja jest prosta: przenosisz ref z drugiego argumentu do propsów:

1// Przed (React 18)
2const OldComponent = forwardRef(function OldComponent(props, ref) {
3  return <div ref={ref}>{props.children}</div>;
4});
5
6// Po (React 19)
7function NewComponent({ ref, children }) {
8  return <div ref={ref}>{children}</div>;
9}
10
11// Użycie pozostaje identyczne!
12function Panel() {
13  const oldRef = useRef(null);
14  const newRef = useRef(null);
15
16  return (
17    <>
18      <OldComponent ref={oldRef}>Stary</OldComponent>
19      <NewComponent ref={newRef}>Nowy</NewComponent>
20    </>
21  );
22}

Miejsca użycia się nie zmieniają, więc możesz migrować komponent po komponencie. Nie musisz się też spieszyć: forwardRef w React 19 nadal działa i nie jest jeszcze oznaczony jako przestarzały (deprecated), dokumentacja zapowiada to dopiero w przyszłej wersji.

Podsumowanie

React 19 wprowadza znaczące uproszczenie w przekazywaniu referencji:

  1. ref jest zwykłym propem - w komponentach funkcyjnych nie wymaga specjalnego traktowania
  2. forwardRef nie jest już potrzebny - nadal działa, a dokumentacja zapowiada, że w przyszłej wersji zostanie oznaczony jako przestarzały (deprecated)
  3. useImperativeHandle działa bez forwardRef - przekaż ref jako prop
  4. Callback refs działają jak dawniej - a od React 19 mogą też zwrócić funkcję sprzątającą, którą React wywoła przy odłączeniu elementu
  5. TypeScript - ref opisujesz w typie propsów jak każdy inny prop, np. React.Ref<HTMLInputElement>

W nowym kodzie polecam Ci od razu pisać komponenty z ref w propsach, a stary kod migrować przy okazji innych zmian. Przyda się to w każdej bibliotece komponentów, bo przyciski i pola muszą udostępniać ref do fokusu i pomiarów. To uproszczenie sprawia, że komponenty są bardziej przewidywalne i łatwiejsze do zrozumienia.

Pamiętaj: w React 19 ref to zwykły kanał łączności między modułami - podłączasz go jak każdy inny prop, bez dodatkowego przekaźnika. W przykładzie poniżej stary przycisk z forwardRef i nowe komponenty z ref w propsach działają obok siebie, a okno dialogowe udostępnia rodzicowi metody przez useImperativeHandle.

Kod do tej lekcji: App.jsx
1import { forwardRef, useImperativeHandle, useRef, useState } from 'react';
2
3// React 19: ref jest zwykłym propem komponentu funkcyjnego.
4// forwardRef nadal działa, ale nie jest już potrzebny.
5
6// Stary sposób (React 18) - wciąż działa w React 19
7const OldSpaceButton = forwardRef(function OldSpaceButton(props, ref) {
8  return <button ref={ref} style={{ ...styles.button, background: '#778da9' }} {...props} />;
9});
10
11// Nowy sposób w React 19 - ref wyciągasz z propsów jak każdą inną wartość
12function SpaceButton({ ref, children, variant = 'primary', ...props }) {
13  const colors = {
14    primary: { background: '#00d4ff', color: '#0a0e27' },
15    danger: { background: '#ff6b6b', color: '#fff' },
16    success: { background: '#00ff88', color: '#0a0e27' },
17  };
18
19  return (
20    <button ref={ref} style={{ ...styles.button, ...(colors[variant] || colors.primary) }} {...props}>
21      {children}
22    </button>
23  );
24}
25
26function SpaceInput({ ref, label, ...props }) {
27  return (
28    <label style={styles.field}>
29      <span style={styles.label}>{label}</span>
30      <input ref={ref} style={styles.input} {...props} />
31    </label>
32  );
33}
34
35// useImperativeHandle z ref z propsów: rodzic dostaje tylko open, close i toggle
36function SpaceDialog({ ref, title, children }) {
37  const [isOpen, setIsOpen] = useState(false);
38
39  useImperativeHandle(ref, () => ({
40    open: () => setIsOpen(true),
41    close: () => setIsOpen(false),
42    toggle: () => setIsOpen(prev => !prev),
43  }), []);
44
45  if (!isOpen) return null;
46
47  return (
48    <div style={styles.overlay}>
49      <div style={styles.dialog}>
50        <h3 style={{ color: '#00d4ff', margin: '0 0 12px' }}>{title}</h3>
51        {children}
52        <button onClick={() => setIsOpen(false)} style={styles.closeButton}>Zamknij</button>
53      </div>
54    </div>
55  );
56}
57
58export default function App() {
59  const buttonRef = useRef(null);
60  const oldButtonRef = useRef(null);
61  const inputRef = useRef(null);
62  const dialogRef = useRef(null);
63  const [log, setLog] = useState([]);
64
65  const addLog = message => setLog(prev => [message, ...prev].slice(0, 6));
66
67  return (
68    <div style={styles.page}>
69      <div style={{ maxWidth: '600px', margin: '0 auto' }}>
70        <h1 style={styles.title}>React 19: ref jako prop</h1>
71        <p style={styles.subtitle}>forwardRef nie jest już potrzebny, choć nadal działa</p>
72
73        <section style={styles.card}>
74          <h3 style={styles.cardTitle}>Przycisk z ref w propsach</h3>
75          <div style={styles.row}>
76            <SpaceButton ref={buttonRef} onClick={() => addLog('Kliknięto przycisk z ref')}>
77              Uruchom silniki
78            </SpaceButton>
79            <SpaceButton variant="success" onClick={() => { buttonRef.current?.focus(); addLog('Fokus na przycisku (nowy sposób)'); }}>
80              Ustaw fokus
81            </SpaceButton>
82          </div>
83        </section>
84
85        <section style={styles.card}>
86          <h3 style={styles.cardTitle}>Stary przycisk z forwardRef</h3>
87          <div style={styles.row}>
88            <OldSpaceButton ref={oldButtonRef} onClick={() => addLog('Kliknięto stary przycisk')}>
89              Stary przekaźnik
90            </OldSpaceButton>
91            <SpaceButton variant="success" onClick={() => { oldButtonRef.current?.focus(); addLog('Fokus na przycisku (forwardRef)'); }}>
92              Ustaw fokus
93            </SpaceButton>
94          </div>
95        </section>
96
97        <section style={styles.card}>
98          <h3 style={styles.cardTitle}>Pole tekstowe z ref w propsach</h3>
99          <SpaceInput ref={inputRef} label="Nazwa misji" placeholder="Wpisz nazwę..." />
100          <SpaceButton onClick={() => { inputRef.current?.focus(); addLog('Fokus na polu tekstowym'); }}>
101            Ustaw fokus na polu
102          </SpaceButton>
103        </section>
104
105        <section style={styles.card}>
106          <h3 style={styles.cardTitle}>Okno dialogowe z useImperativeHandle</h3>
107          <div style={styles.row}>
108            <SpaceButton onClick={() => { dialogRef.current?.open(); addLog('Otwarto okno'); }}>Otwórz okno</SpaceButton>
109            <SpaceButton variant="danger" onClick={() => { dialogRef.current?.close(); addLog('Zamknięto okno'); }}>Zamknij okno</SpaceButton>
110          </div>
111        </section>
112
113        <SpaceDialog ref={dialogRef} title="Komunikat z mostka">
114          <p style={{ margin: 0 }}>To okno udostępnia rodzicowi tylko metody open, close i toggle przez useImperativeHandle.</p>
115        </SpaceDialog>
116
117        <section style={styles.card}>
118          <h3 style={styles.cardTitle}>Dziennik zdarzeń</h3>
119          {log.length === 0 ? (
120            <p style={styles.muted}>Kliknij przyciski powyżej...</p>
121          ) : (
122            log.map((entry, i) => <div key={i} style={styles.logRow}>{entry}</div>)
123          )}
124        </section>
125      </div>
126    </div>
127  );
128}
129
130const styles = {
131  page: { minHeight: '100vh', background: 'linear-gradient(135deg, #0a0e27 0%, #1a1a3e 100%)', padding: '24px', color: '#e0e1dd', fontFamily: 'system-ui, sans-serif' },
132  title: { textAlign: 'center', color: '#00d4ff', margin: '0 0 6px' },
133  subtitle: { textAlign: 'center', color: '#8892b0', margin: '0 0 18px', fontSize: '14px' },
134  card: { background: 'rgba(0,0,0,0.3)', padding: '16px', borderRadius: '12px', marginBottom: '12px' },
135  cardTitle: { color: '#64ffda', margin: '0 0 10px', fontSize: '15px' },
136  row: { display: 'flex', gap: '8px', flexWrap: 'wrap' },
137  button: { padding: '9px 16px', border: 'none', borderRadius: '8px', cursor: 'pointer', fontWeight: 'bold', fontSize: '14px', color: '#0a0e27' },
138  field: { display: 'block', marginBottom: '10px' },
139  label: { display: 'block', color: '#8892b0', fontSize: '13px', marginBottom: '4px' },
140  input: { width: '100%', boxSizing: 'border-box', padding: '9px 12px', background: 'rgba(255,255,255,0.08)', border: '1px solid rgba(0,212,255,0.3)', borderRadius: '8px', color: '#e0e1dd', fontSize: '14px' },
141  overlay: { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 },
142  dialog: { background: '#1a1a3e', padding: '24px', borderRadius: '12px', border: '1px solid rgba(0,212,255,0.3)', maxWidth: '400px', width: '90%' },
143  closeButton: { marginTop: '16px', padding: '8px 16px', background: 'rgba(255,255,255,0.1)', color: '#e0e1dd', border: '1px solid rgba(255,255,255,0.2)', borderRadius: '6px', cursor: 'pointer' },
144  muted: { color: '#8892b0', fontSize: '13px', margin: 0 },
145  logRow: { fontSize: '12px', color: '#8892b0', padding: '2px 0', fontFamily: 'monospace' },
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. Jak przekazać ref do komponentu funkcyjnego w React 19?

  2. 2. Jaki jest status forwardRef w React 19?

Zadania praktyczne w grze

  • Klikanie w kolejności

    Ułóż nowy sposób definiowania komponentu z ref w React 19:

Przydatne artykuły