Kliensoldali webprogramozás

Kompozíció. Adatok lecsorgatása. Context. Hooks.

Horváth Győző
Egyetemi docens
1117 Budapest, Pázmány Péter sétány 1/c., 2.408-as szoba
Tel: (1) 372-2500/8469
horvath.gyozo@inf.elte.hu

Ismétlés

Felhasználói felület kezelése

  • Egységbe zárt logika: komponensek
  • Deklaratív kiírás: rerender
  • Hatékony megoldás: virtuális DOM
  • → React
  • Komponensalapú fejlesztés

React alapok

  • kiírás
    • statikus → komponensek
  • adat
    • props
    • state
  • bemenet
    • események
    • setState
  • Data down, action down
  • Életciklus függvények

Komponens kétféleképpen

// functional component
function ColorBox() {
  return (
    <div style={{ backgroundColor: `hsl(${random(0, 360)}, 50%, 50%)` }}>
      React rendered box, random value: {random(1, 100)}
    </div>
  )
}
// class component
class ColorBox extends React.Component {
  render() {
    return (
      <div style={{ backgroundColor: `hsl(${random(0, 360)}, 50%, 50%)` }}>
        React rendered box, random value: {random(1, 100)}
      </div>
    ) 
  }
}

Adat

props state
nem módosítható módosítható
immutábilis mutábilis
kívülről érkezik belül definiált
this.props this.state
props useState()
állapotmentes komponens állapotteli komponens

Adat helye

Data down, action up

Példa

function Card() {
  // Component state
  const [filterText, setFilterText] = useState("");
  const [titles, setTitles] = useState(["title1", "title2", "item3"]);
  // Computed values
  const filteredTitles = titles.filter((e) => e.includes(filterText));
  // Event handlers
  const changeFilterText = (newVal) => setFilterText(newVal);

  return (
    <div className="card text-center">
      <CardHeader />
      <div className="card-body">
        <FilterForm
          filterText={filterText}
          onFilterTextChange={changeFilterText}
        />
        <FilterList list={filteredTitles} />
      </div>
      <CardFooter list={filteredTitles} />
    </div>
  );
}

function FilterForm({ filterText, onFilterTextChange }) {
  return (
    <form className="form-inline">
      <label htmlFor="inlineFormInputName2">Filter</label>
      <input
        value={filterText}
        onInput={(e) => onFilterTextChange(e.target.value)}
        type="text"
        className="form-control m-2"
        id="text-filter"
      />
    </form>
  );
}

React specifikumok

Renderelés lépései

  • Trigger
    • createRoot.render
    • state változik
  • Render
    • Függvénykomponensek hívása
    • Kompozíció
  • Commit
    • DOM műveletek

Első vs további render

  • Első
    • legfelső szintről
    • elemek létrehozása
    • elemek DOM-ba rakása
  • Későbbi
    • adott szintről
    • tulajdonságok összehasonlítása
    • elemek módosítása

Renderelés és adatok

  • Renderelés
    • Függvények meghívása során
    • Tiszta függvények
      • Csak JSX generálás
      • Semmit sem változtatva (mellékhatás)
  • Adat
    • Minimálisan szükséges adatok az alkalmazás megjelenítéséhez
    • Beégetett kezdeti értékek
    • Számított értékek
    • Lokális változók, paraméterek
  • Minden rendereléskor új adatok jönnek létre!
    • (closure)

Nem tiszta renderelés

let guest = 0;

function Cup() {
  // Bad: changing a preexisting variable!
  guest = guest + 1;
  return <h2>Tea cup for guest #{guest}</h2>;
}

export default function TeaSet() {
  return (
    <>
      <Cup />
      <Cup />
      <Cup />
    </>
  );
}

Strict Mode: kétszer csatolja fel a komponenseket!

Számított értékek

function Todos() {
  // data
  const todos = [
    { title: "todo1", active: true  },
    { title: "todo2", active: false },
    { title: "todo3", active: true  },
  ];

  // computed/derivated data
  const activeTodos = todos.filter(todo => todo.active);

  // JSX
  return <TodoList list={activeTodos} />;
}

Trigger

  • Miért kell rerender?
    • Mert az adat már nem jó!
    • Megváltozott!
    • Újra kell rajzolni
  • Trigger
    • Adatváltoztatás = mellékhatás
    • Eseménykezelés: felhasználói interakció
    • Effektek: renderelés mellékhatása

Eseménykezelő + lokális változók

  • Eseménykezelő
    • Komponensen belüli függvény
    • Closure
    • Belső állapotot változtat
  • Lokális változók
    • Nem őrződnek meg renderelések között
    • Nem váltanak ki újrarajzolást (trigger)
function LocalVariable() {
  let n = 3;
  const increment = () => { n = n + 1; };
  return (
    <>
      <button onClick={increment}>Increment</button>
      <p>N = {n}</p>
    </>
  );
}

Belső állapotváltozók

  • Belső állapot
    • useState
    • a komponens memóriája
    • renderelésen kívüli adat
    • újrarajzolást vált ki
    • JSX generálás során csak olvassuk
    • eseménykezelőben írjuk

Belső állapotváltozók

function StateVariable() {
  const [n, setN] = useState(0); // let n = 3;
  const increment = () => {
    setN(n + 1); // n = n + 1;
  };
  return (
    <>
      <button onClick={increment}>Increment</button>
      <p>N = {n}</p>
    </>
  );
}

Set state és trigger

Ha a state megváltozik, akkor van trigger!

function Rerender() {
  const [n, setN] = useState(3);
  return (
    <>
      <button onClick={() => setN(3)}>Change to 3</button>
      <button onClick={() => setN(5)}>Change to 5</button>
      <p>N = {n}</p>
    </>
  );
}

Állapot lokális

Komponensenként külön

function StateVariable() {
  return (
    <>
      <Incrementer />
      <Incrementer />
    </>
  );
}
function Incrementer() {
  const [n, setN] = useState(0);
  const increment = () => {
    setN(n + 1);
  };
  return (
    <>
      <button onClick={increment}>Increment</button>
      <p>N = {n}</p>
    </>
  );
}

Állapot pillanatkép

Külső tárolóból lokális változóba olvassuk -> closure

function StateVariable3() {
  const [n, setN] = useState(0);
  const handleAlertClick = () => {
    setTimeout(() => {
      alert("You clicked on: " + n);
    }, 3000);
  };
  return (
    <>
      <button onClick={() => setN(n + 1)}>Increment</button>
      <button onClick={handleAlertClick}>Alert</button>
      <p>N = {n}</p>
    </>
  );
}

Állapot pillanatkép

  • Eseménykezelő, kötegelt setterek, rerender
  • Behelyettesítés
function StateVariable() {
  const [n, setN] = useState(0);
  const increment = () => {
    setN(n + 1);
    setN(n + 1);
    setN(n + 1);
  };
  // ...
}
// State updater function
function StateVariable() {
  const [n, setN] = useState(0);
  const increment = () => {
    setN(n => n + 1);
    setN(n => n + 1);
    setN(n => n + 1);
  };
  // ...
}

Állapot pillanatkép

Mennyi lesz n értéke?

setN(n + 1);
alert(n);
setN(n + 1);
setTimeout(() => {
  alert(n);
}, 3000);
setN(n + 10);
setN((n) => n + 1);
setN(n + 10);
setN((n) => n + 1);
setN(42);

Az állapot immutábilis

  • Referenciatípusok esetén
  • Tekints minden állapotváltozót csak olvashatónak
  • Módosítani csak setteren keresztül
  • Mindig készíts másolatot róla

DEMO

Referenciatípusok

Mutábilis változásokkor a referencia megmarad

function App() {
  const [numbers, setNumbers] = useState([]);

  // Wrong
  const addNumber = () => {
    numbers.push(Math.random())
    setNumbers(numbers)
  }

  return (
    <>
      <button onClick={addNumber}>Add</button>
      <ul>
        {numbers.map(n => <li key={n}>{n}</li>)}
      </ul>
    </>
  );
}

Referenciatípusok

Immutábilis változásokkor új struktúra jön létre

function App() {
  const [numbers, setNumbers] = useState([]);

  // Good
  const addNumber = () => setNumbers([...numbers, Math.random()])
  // or
  const addNumber = () => setNumbers(numbers => [...numbers, Math.random()])

  return (
    <>
      <button onClick={addNumber}>Add</button>
      <ul>
        {numbers.map(n => <li key={n}>{n}</li>)}
      </ul>
    </>
  );
}

Mutábilis vs immutábilis

const obj = { a: 1, b: 2 }
// Mutable
// still the same object outside, but the contents have changed
obj.b = 3
// Immutable
const obj2 = Object.assign({}, obj, { b: 3 })
const obj2 = {...obj, b: 3 }

Mutábilis vs immutábilis

const arr = ['a', 'b']
// Mutable
arr.push('c')
// Immutable
const arr2 = arr.concat('c')
const arr3 = [...arr, 'c']

// or, we can make a copy of the original array:
const arr4 = arr.slice()
// and mutate the copy:
arr4.push('c')

Mutábilis vs immutábilis

const arr = ['a', 'b', 'c', 'd']
// Mutable
arr[1] = 'q'
// Immutable
const arr2 = [...arr.slice(0, 1), 'q', ...arr.slice(2)]

const arr3 = arr.slice()
arr3[1] = 'q'

Immutábilis változtatások

const obj = {
  a: {
    // To safely update obj.a.c, we have to copy each piece
    c: 3
  },
  b: 2
}

const obj2 = {
  // copy obj
  ...obj,
  // overwrite a
  a: {
    // copy obj.a
    ...obj.a,
    // overwrite c
    c: 42
  }
}

Az állapottér szerkezete

  • Összetartozó adatok csoportosítása (objektum)
  • Ellentmondások elkerülése
  • Redundáns információk megszüntetése
  • Duplikációk megszüntetése
  • Mélyen egymásba ágyazott struktúrák megszüntetése

React dokumentáció

Az állapot életciklusa

Az állapot helye

  • Az állapot lokális és izolált
  • Ha ugyanazt az állapotot szeretnénk használni, akkor emeljük fel az állapotot egy közös ősbe!
  • Adat átadása a gyerekkomponenseknek
  • Data down

Data down

Prop drilling

const Toggle = () => {
  const [on, setOn] = useState(false)
  const toggle = () => setOn(!on)
  return <Switch on={on} onToggle={toggle} />
}
const Switch = ({on, onToggle}) => (
  <div>
    <SwitchMessage on={on} />
    <SwitchButton onToggle={onToggle} />
  </div>
)
const SwitchMessage = ({on}) =>
  <div>The button is {on ? 'on' : 'off'}</div>

const SwitchButton = ({onToggle}) =>
  <button onClick={onToggle}>Toggle</button>

Prop drilling

Prop drilling

Object spread operator

function App1() {
  return <Greeting firstName="Ben" lastName="Hector" />;
}
function App2() {
  const props = {firstName: 'Ben', lastName: 'Hector'};
  return <Greeting {...props} />;
}
const Button = props => {
  const { kind, ...other } = props;
  const className = kind === "primary" ? "PrimaryButton" : "SecondaryButton";
  return <button className={className} {...other} />;
};
const App = () => 
  <div>
    <Button kind="primary" onClick={() => console.log("clicked!")}>
      Hello World!
    </Button>
  </div>

Prop drilling

  • Előny
    • explicit
    • átlátható, nyomon követhető
    • tesztelhető
  • Hátrány
    • nagy alkalmazás, mély fa, sok munka
    • adat refaktorálása
    • több prop átadása
    • kevesebb prop átadása + defaultProps
    • átnevezés menet közben

Object spread nem explicit! Nem jó!

Prop drilling

  • Hátrányok kiküszöbölése
    • nem kell mindent szétszedni
    • implicitté tenni az átadást
  • Implicit
    • Object spread
    • Context
    • Kompozíció

Implicit átadás

Implicit átadás

Context

Prop drilling

function App() {
  return <Toolbar theme="dark" />
}

function Toolbar(props) {
  return (
    <div>
      <ThemedButton theme={props.theme} />
    </div>
  );
}

function ThemedButton(props) {
  return <Button theme={props.theme} />;
}

Context

const ThemeContext = ✒>React.createContext('light')<✒;

function App() {
  return (
    ✒><ThemeContext.Provider value="dark"><✒
      <Toolbar />
    </ThemeContext.Provider>
  )
}

function Toolbar() {
  return (
    <div>
      <ThemedButton />
    </div>
  );
}

function ThemedButton() {
  const theme = useContext(ThemeContext);
  render() {
    return <Button theme={✒>theme<✒} />;
  }
}

Használata

  • “globális” adatok
    • téma
    • bejelentkezett felhasználó
    • kiválasztott nyelv
  • sok komponenst érint
  • eltérő mélységekben
  • több provider is lehet
  • hátránya
    • tesztelés nehézkes
    • újrahasznosítás nehézkes

Context helyett kompozíció

  • komponens leküldése propban
    • prop-ok számát csökkentheti
    • szülőben nő a komplexitás
  • tartalmazás
  • render prop
    • gyerek kommunikál a szülővel render előtt

Példa a dokumentációból

Dinamikus context

const CounterContext = React.createContext();

function CounterProvider({ children }) {
  const [counter, setCounter] = useState(0);
  increaseCounter = () => setCounter(counter => counter + 1);
  return (
    <CounterContext.Provider value={{ counter, increaseCounter }}>
      {children}
    </CounterContext.Provider>
  );
}

function Button() {
  const { counter, increaseCounter } = useContext(CounterContext);
  return <button onClick={increaseCounter}>Clicked {counter} times</button>;
}

function Application() {
  return (
    <CounterProvider>
      <Button />
    </CounterProvider>
  )
};

További hookok

Amikor a prop és a state nem elég

Ref hook

  • ~ Olyan state, ami nem vált ki re-rendert
  • ~ Osztály adattag
const ref = useRef(0);
// ref
{ 
  current: 0
}

// Inside of React
function useRef(initialValue) {
  const [ref, unused] = useState({ current: initialValue });
  return ref;
}

Ref használata

  • State
    • olyan adat, ami szükséges a megjelenítéshez
    • trigger
  • Ref
    • külső API kommunikáció
    • időzítők
    • DOM API
    • olyan objektumok, amikre nincs szükség a JSX genrálásához
    • ritka, mutábilis, nincs snapshot, POJO

Ref hook

DOM API

function TextInputWithFocusButton() {
  const inputEl = useRef(null);
  const onButtonClick = () => {
    // `current` points to the mounted text input element
    inputEl.current.focus();
  };
  return (
    <>
      <input ref={inputEl} type="text" />
      <button onClick={onButtonClick}>Focus the input</button>
    </>
  );
}

Ref hook

Timer API, példa

import { useState, useRef } from 'react';

export default function Stopwatch() {
  // This information is used for rendering, so you’ll keep it in state
  const [startTime, setStartTime] = useState(null);
  const [now, setNow] = useState(null);
  // Since the interval ID is not used for rendering, you can keep it in a ref
  const intervalRef = useRef(null);

  function handleStart() {
    setStartTime(Date.now());
    setNow(Date.now());

    clearInterval(intervalRef.current);
    intervalRef.current = setInterval(() => {
      setNow(Date.now());
    }, 10);
  }

  function handleStop() {
    clearInterval(intervalRef.current);
  }

  let secondsPassed = 0;
  if (startTime != null && now != null) {
    secondsPassed = (now - startTime) / 1000;
  }

  return (
    <>
      <h1>Time passed: {secondsPassed.toFixed(3)}</h1>
      <button onClick={handleStart}>
        Start
      </button>
      <button onClick={handleStop}>
        Stop
      </button>
    </>
  );
}

Effect hook

  • Mellékhatások
    • renderelés: tiszta (mellékhatás mentes)
    • eseménykezelés: adatváltoztatás (felhasználói kezdeményezés)
    • effekt: adatváltozás a renderelésből fakadóan
  • Effekt: külső rendszerekkel történő szinkronizáció
    • DOM manipuláció
    • BOM manipuláció
    • szerver adatcsere
    • animáció
  • Imperatív megoldások a deklaratív világban
  • Több is lehet, szeparáltan

Effect hook

Minden rerenderkor lefut

import React, { useState, useEffect } from 'react';

function Example() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    document.title = `You clicked ${count} times`;
  });

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

Effect hook

Futás kihagyása

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  useEffect(() => {
    if (isPlaying) {
      console.log('Calling video.play()');
      ref.current.play();
    } else {
      console.log('Calling video.pause()');
      ref.current.pause();
    }
  }, [isPlaying]);

  return <video ref={ref} src={src} loop playsInline />;
}

Effect hook

  • Cleanup
    • függvény visszatérési értékként
    • minden rerender előtt lefut
  • Strict mode!
useEffect(() => {
  const connection = createConnection();
  connection.connect();
  return () => {
    connection.disconnect();
  };
}, []);

Effect hook

Végtelen ciklus

const [count, setCount] = useState(0);
useEffect(() => {
  setCount(count + 1);
});

Effect hook

  • Ritkán kell használni
  • Valószínűleg nincs szükséged rá
    • pl. ha nincs külső rendszer és set state van az effektben
  • React dokumentáció

useCallback

  • Függvény cache-elése rerenderek között
  • Például rerenderelések optimalizálása
    • eseménykezelő
function Foo() {
  const handleClick = () => console.log('click');
  return (
    <MyButton onClick={handleClick}>Click</MyButton>
  );
}
function Foo() {
  const handleClick = useCallback(() => console.log('click'), []);
  return (
    <MyButton onClick={handleClick}>Click</MyButton>
  );
}

useMemo

  • Optimalizáció
  • Nem mindig számolódik ki, csak ha a függőségei változnak
const memoizedValue = useMemo(
  () => computeExpensiveValue(a, b), 
  [a, b]
);

useMemo példa

import { useState } from 'react';
export function CalculateFactorial() {
  const [number, setNumber] = useState(1);
  const [inc, setInc] = useState(0);
  const factorial = factorialOf(number);
  const onChange = event => {
    setNumber(Number(event.target.value));
  };
  const onClick = () => setInc(i => i + 1);
  
  return (
    <div>
      Factorial of 
      <input type="number" value={number} onChange={onChange} />
      is {factorial}
      <button onClick={onClick}>Re-render</button>
    </div>
  );
}
function factorialOf(n) {
  console.log('factorialOf(n) called!');
  return n <= 0 ? 1 : n * factorialOf(n - 1);
}

useMemo példa

import { useState, useMemo } from 'react';
export function CalculateFactorial() {
  const [number, setNumber] = useState(1);
  const [inc, setInc] = useState(0);
  const factorial = useMemo(() => factorialOf(number), [number]);
  const onChange = event => {
    setNumber(Number(event.target.value));
  };
  const onClick = () => setInc(i => i + 1);
  
  return (
    <div>
      Factorial of 
      <input type="number" value={number} onChange={onChange} />
      is {factorial}
      <button onClick={onClick}>Re-render</button>
    </div>
  );
}
function factorialOf(n) {
  console.log('factorialOf(n) called!');
  return n <= 0 ? 1 : n * factorialOf(n - 1);
}

Egyedi hookok

import { useState, useEffect } from 'react';

export default function StatusBar() {
  const [isOnline, setIsOnline] = useState(true);
  useEffect(() => {
    function handleOnline() {
      setIsOnline(true);
    }
    function handleOffline() {
      setIsOnline(false);
    }
    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);
    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);

  return <h1>{isOnline ? '✅ Online' : '❌ Disconnected'}</h1>;
}

Egyedi hookok

import { useState, useEffect } from 'react';

export function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(true);
  useEffect(() => {
    function handleOnline() {
      setIsOnline(true);
    }
    function handleOffline() {
      setIsOnline(false);
    }
    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);
    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);
  return isOnline;
}

function StatusBar() {
  const isOnline = useOnlineStatus();
  return <h1>{isOnline ? '✅ Online' : '❌ Disconnected'}</h1>;
}

Hookok szabályai

  • Csak felső szinten szabad meghívni őket
    • nem elágazásban
    • nem ciklusban
  • Csak React függvénykomponensekben hívjuk őket
    • ne normális JS függvényekben
    • hanem függvénykomponensben
    • saját hookban

Hookok megértéséhez

JS Conf előadás

const React = (() => {
  let hooks = [];
  let idx = 0;
  function useState(initVal) {
    let state = hooks[idx] || initVal;
    let _idx = idx;
    let setState = newVal => {
      hooks[_idx] = newVal;
    };
    idx++;
    return [state, setState];
  }
  function useRef(val) {
    return useState({ current: val })[0];
  }
  function useEffect(cb, depArray) {
    const oldDeps = hooks[idx];
    let hasChanged = true;
    if (oldDeps) {
      hasChanged = depArray.some(
        (dep, i) => !Object.is(dep, oldDeps[i])
      );
    }
    if (hasChanged) cb();
    hooks[idx] = depArray;
  }
  function render(Component) {
    idx = 0;
    const C = Component();
    C.render();
    return C;
  }
  return {
    useState,
    useEffect,
    useRef,
    render,
  };
})();

function Component() {
  const [count, setCount] = React.useState(1);
  const [text, setText] = React.useState('apple');
  return {
    render: () => console.log({count, text}),
    click: () => setCount(count + 1),
    type: word => setText(word),
  };
}

var app = React.render(Component);
app.click();
var app = React.render(Component);
app.type("Vue");
var app = React.render(Component);

Régi megoldások

Kis történelem

// ES5: object
var HelloMessage = React.createClass({
  render: function() {
    return <div>Hello, {this.props.name}!</div>
  }
});
// ES6: class
class HelloMessage extends React.Component {
  render() {
    return <div>Hello, {this.props.name}</div>
  }
}
// function
function HelloMessage(props) {
  return <div>Hello, {props.name}</div>
}

Különbségek (eleinte)

  • Osztálykomponens (stateful, okos)
    • render
    • props
    • state
    • életciklus függvények
    • context
    • ref
  • Függvénykomponens (stateless, buta)
    • render
    • props

Gondok

  • állapothoz kapcsolódó logika újrahasznosítása nehéz
    • kompozíció, egymásba ágyazás
  • komplex komponensek
    • életciklus függvényekben sokféle logika
  • osztályok kezelése nehézkes
    • pl. this

Hook-ok

  • React szolgáltatásainak elérhetővé tétele a függvénykomponensekben
  • Erősödő funkcionális paradigma
  • Pár dolog újragondolása
  • Tisztább, egyszerűbb kód
  • Jövőbe mutató

State hook

Hook vs Class

function Example() {
  const [count, setCount] = useState(0);







  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

class Example extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      count: 0
    };
  }

  render() {
    return (
      <div>
        <p>You clicked {this.state.count} times</p>
        <button onClick={() => this.setState({ count: this.state.count + 1 })}>
          Click me
        </button>
      </div>
    );
  }
}

Effect hook

Hook vs Class

function Example() {
  const [count, setCount] = useState(0);






  useEffect(() => {
    document.title = `You clicked ${count} times`;
  });





  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

class Example extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      count: 0
    };
  }

  componentDidMount() {
    document.title = `You clicked ${this.state.count} times`;
  }
  componentDidUpdate() {
    document.title = `You clicked ${this.state.count} times`;
  }

  render() {
    return (
      <div>
        <p>You clicked {this.state.count} times</p>
        <button onClick={() => this.setState({ count: this.state.count + 1 })}>
          Click me
        </button>
      </div>
    );
  }
}

Végszó

  • React specifikumok
  • Adat lecsorgatása
    • prop drilling
    • context
  • Hooks
    • useState
    • useEffect
    • useContext
    • useRef
    • useCallback
    • useMemo