Rok 2017 proběhl ve znamení komponent vyššího řádu (Higher-Order Component a.k.a. HOC). Roku 2018 dominuje funkce jako potomek (Function as Child Component, a.k.a. FaCC), a to dokonce tak, že si ji vývojáři Facebooku vybrali jako základ pro své nové Context API. To jsem nedávno vyzkoušel a příliš mě nenadchlo, a to právě kvůli použití FaCC. O tuto zkušenost bych se rád podělil. Berte proto, prosím, na vědomí, že následující text je odrazem mého názoru vycházejícího z jedné zkušenosti a nesnaží se tedy příliš věnovat problému použití HOC a FaCC jako celku.

Function as a Child Component

FaCC je jedním z návrhových vzorů, který se v Reactu používá pro sdílenou funkcionalitu. Obecně nám jde o to, přidat existující komponentě nějakou obecnou funkcionalitu tak, že ji obalíme další komponentou (takzvaná kompozice). Typickým příkladem je HOC connect, který napojuje komponentu na reduxový store. Nebo můžeme chtít komponentu překrýt průhlednou vrstvou a zobrazit indikátor načítání podle nějaké podmínky.

Pro tento účel se vyvinulo více návrhových vzorů. Hodně používané byly komponenty vyššího řádu, tedy funkce, které měly jako argument komponentu a vracely novou komponentu, obsahující tu původní. Některé knihovny, především redux-form používají něco, co bych nazval component props (také jsem slyšel název component injection), tedy komponentu, která má jako props jinou komponentu a tu vykreslí (viz komponenta Field).

FaCC vychází ze staršího vzoru render props, kdy komponenta má jako props funkci, která vrací React element. Rozdíl oproti component props je v tom, že render props je klasická funkce s více argumenty, zatímco component props má jen jeden argument, a to objekt props, který se destrukturalizuje. FaCC pak místo props render používá props children, píše se tedy přímo jako obsah JSX tagu. Rozdíl je čistě vizuální: Tím, že FaCC se píše dovnitř JSX tagu, může být mnohem delší a stále vypadá dobře, i když psát funkci místo dětí může vypadat nezvykle.


const DisplayCoordinates => ({x, y}) => (
    <dl>
        <dt>X:</dt>
        <dd>{x}</dd>
        <dt>Y:</dt>
        <dd>{y}</dd>
    </dl>
);
 
// Higher-Order Component
const Component = withCoordinates(Display);
<component></component>;
 
// Component props
<coordinates component={DisplayCoordinates}></coordinates>
 
// Function as Child Component
<coordinates>
    {(x, y) => (
        <dl>
            <dt>X:</dt>
            <dd>{x}</dd>
            <dt>Y:</dt>
            <dd>{y}</dd>
        </dl>
    }
</coordinates>

Problém s Context API

Nedávno jsem řešil úlohu, kdy na stránce je více rozbalovacích panelů a rozbalený může být v jeden okamžik nejvýše jeden. Z důvodu perzistence stavu přes taby a toho, že občas bylo třeba otevřít konkrétní panel z kódu, jsme se rozhodli ukládat otevřený panel do reduxu. Každý panel tak má svůj identifikátor a všechny panely jsou obaleny komponentou, která do kontextu vkládá identifikátor tabu. S použitím nového Context API (mimochodem, šlo by zde použít i to staré) jsem došel k následujícímu kódu:


const mapStateToProps = (state, {name: panelName, tabName}) => ({
    expanded: isExpanded(state, tabName, panelName),
});
 
const mapDispatchToProps = (state, {name: panelName, tabName}) => ({
    createToggleExpanded: (expanded) => () => setExpaned(tabName, panelName, !expanded),
});
 
const mergeProps = ({expanded}, {createToggleExpanded}) => ({
    expanded,
    toggleExpanded: createToggleExpanded(expanded),
});
 
const Connected = connect(mapStateToProps, mapDispatchToProps, mergeProps)(CollapsiblePanel);
 
const WithContext = ({name}) => (
    <collapsiblecontext .Provider>
        {(tabName) => <connected {...{name, tabName}}></connected>}
    </collapsiblecontext>
);
 
WithContext.propTypes = {
    name: PropTypes.string.isRequired,
};
 
export default WithContext;

Zvláště mě zamrzel boilerplate s předáváním props v komponentě WithContext, který znamená další místo, na které si musím dávat pozor, když přidám nějakou props. Přitom s použitím HOC (kterou jsem si potom stejně vytvořil) by kód mohl vypadat takto:


const CollapsiblePanel = compose(
    withContext({pageName: CollapsibleContext}),
    connect(mapStateToProps, mapDispatchToProps, mergeProps),
)(CollapsiblePanelComponent);

Takhle si představuju jednoduchou kompozici komponent a ne jako tu hrůzu s předáváním propsů. Přitom podobné návrhové vzory by měly sloužit k tomu, aby se neopakoval kód; pokud jejich použití opakující se kód generuje, je zřejmě někde něco špatně.

Diskuse

Netvrdím, že je FaCC špatný návrhový vzor a neměl by se používat. Ve výše uvedeném příkladu se však projevila jeho slabina: není prostupný pro props. V některých případech to nemusí být problém. Jindy, například ve spojení s connect, to zjevně problém je. Možná to souvisí s tím, že connect je HOC, a kdyby byl FaCC, lépe by se s kontextem snesl. Problém prostupnosti pro props to však neřeší. Může to být také způsobeno mou dekompozicí obrazovky, kdy se snažím o malé napojené komponenty.

Ve výsledku to pro mě znamená, že pokud chci použít nějaký návrhový vzor, měl bych vědět, jaké má vlastnosti a jak se chová. Trochu mě překvapuje, že vývojáři ve Facebooku, když Context API navrhovali, na podobný problém nenarazili.

Osobně jsem spíše proti použití FaCC. Volím raději HOC a nebo component props. Ty mají samozřejmě své vlastní problémy, například konflikty v názvu props obalující a obalované komponenty. Překvapuje mě ale nekritické nadšení z FaCC a myslím, že všechny návrhové vzory mají své použití. Koneckonců i mi se nedávno podařilo poměrně elegantně využít FaCC v případě podmíněné formulářové sekce, kde se typicky props dolů nepředávají.


const ConditionalSection = ({test, children, component}) => test &amp;&amp; React.createElement(component || children);