theKashey / use-sidecar

Another way to code splitting
MIT License
106 stars 10 forks source link

🏎 side car


Alternative way to code splitting
Build status npm downloads bundle size

UI/Effects code splitting pattern

Terminology:

UI is a view, sidecar is the logic for it. Like Batman(UI) and his sidekick Robin(effects).

Concept

Rules

That would form two different code branches, you may load separately - UI first, and effect sidecar later. That also leads to a obvious consequence - one sidecar may export all sidecars.

except medium.read, which synchronously read the data from a medium, and medium.assingSyncMedium which changes useMedium to be sync.

SSR and usage tracking

Sidecar pattern is clear:

Thus - no usage tracking, and literally no SSR. It's just skipped.

API

createMedium()

// like useEffect(() => medium.useMedium(someData), []);

medium.assignMedium(someDataProcessor)

// createSidecarMedium is a helper for createMedium to create a "sidecar" symbol const effectCar = createSidecarMedium();


> ! For consistence `useMedium` is async - sidecar load status should not affect function behavior,
thus effect would be always executed at least in the "next tick". You may alter
this behavior by using `medium.assingSyncMedium`.

## exportSidecar(medium, component)
- Type: HOC
- Goal: store `component` inside `medium` and return external wrapper
- Solving: decoupling module exports to support exporting multiple sidecars via a single entry point.
- Usage: use to export a `sidecar`
- Analog: WeakMap
```js
import {effectCar} from './medium';
import {EffectComponent} from './Effect';
// !!! - to prevent Effect from being imported
// `effectCar` medium __have__ to be defined in another file
// const effectCar = createSidecarMedium();
export default exportSidecar(effectCar, EffectComponent);

sidecar(importer)

<>

</>

### Importing `exportedSidecar`
Would require additional prop to be set - ```<Sidecar sideCar={effectCar} />```

## useSidecar(importer)
- Type: hook, loads a `sideCar` using provided `importer` which shall follow React.lazy API
- Goal: to load a side car without displaying any "spinners".
- Usage: load side car for a component
- Analog: none
```js
import {useSidecar} from 'use-sidecar';

const [Car, error] = useSidecar(() => import('./sideCar'));
return (
  <>
    {Car ? <Car {...props} /> : null}
    <UIComponent {...props}>
  </>
); 

Importing exportedSideCar

You have to specify effect medium to read data from, as long as export itself is empty.

import {useSidecar} from 'use-sidecar';

/* medium.js: */ export const effectCar = useMedium({});
/* sideCar.js: */export default exportSidecar(effectCar, EffectComponent);

const [Car, error] = useSidecar(() => import('./sideCar'), effectCar); 
return (
  <>
    {Car ? <Car {...props} /> : null}
    <UIComponent {...props}>
  </>
);

renderCar(Component)

  • Type: HOC, moves renderProp component to a side channel
  • Goal: Provide render prop support, ie defer component loading keeping tree untouched.
  • Usage: Provide defaults and use them until sidecar is loaded letting you code split (non visual) render-prop component
  • Analog: - Analog: code split library like react-imported-library or @loadable/lib.
    
    import {renderCar, sidecar} from "use-sidecar";
    const RenderCar = renderCar(
    // will move side car to a side channel
    sidecar(() => import('react-powerplug').then(imports => imports.Value)),
    // default render props
    [{value: 0}]  
    );
{({value}) => {value}}

## setConfig(config)
```js
setConfig({
  onError, // sets default error handler
});

Examples

Deferred effect

Let's imagine - on element focus you have to do "something", for example focus anther element

Original code

onFocus = event => {
  if (event.currentTarget === event.target) {
    document.querySelectorAll('button', event.currentTarget)
  }
}

Sidecar code

  1. Use medium (yes, .3)
    // we are calling medium with an original event as an argument
    const onFocus = event => focusMedium.useMedium(event);
  2. Define reaction
    
    // in a sidecar

// we are setting handler for the effect medium // effect is complicated - we are skipping event "bubbling", // and focusing some button inside a parent focusMedium.assignMedium(event => { if (event.currentTarget === event.target) { document.querySelectorAll('button', event.currentTarget) } });

1. Create medium
Having these constrains - we have to clone `event`, as long as React would eventually reuse SyntheticEvent, thus not
preserve `target` and `currentTarget`. 
```js
// 
const focusMedium = createMedium(null, event => ({...event}));

Now medium side effect is ok to be async

Example: Effect for react-focus-lock - 1kb UI, 4kb sidecar

Medium callback

Like a library level code splitting

Original code

import {x, y} from './utils';

useEffect(() => {
  if (x()) {
    y()
  }
}, []);

Sidecar code

// medium
const utilMedium = createMedium();

// utils
const x = () => { /* ... */};
const y = () => { /* ... */};

// medium will callback with exports exposed
utilMedium.assignMedium(cb => cb({
 x, y
}));

// UI
// not importing x and y from the module system, but would be given via callback
useEffect(() => {
  utilMedium.useMedium(({x,y}) => {
      if (x()) {
        y()
      }
  })
}, []);
  • Hint: there is a easy way to type it
    const utilMedium = createMedium<(cb: typeof import('./utils')) => void>();

Example: Callback API for react-focus-lock

Split effects

Lets take an example from a Google - Calendar app, with view and logic separated. To be honest - it's not easy to extract logic from application like calendar - usually it's tight coupled.

Original code

const CalendarUI = () => { 
  const [date, setDate] = useState();
  const onButtonClick = useCallback(() => setDate(Date.now), []);

  return (
    <>
     <input type="date" onChange={setDate} value={date} />
     <input type="button" onClick={onButtonClick}>Set Today</button>
    </>
  )
}

Sidecar code

const CalendarUI = () => {
  const [events, setEvents] = useState({});
  const [date, setDate] = useState();

  return (
    <>
     <Sidecar setDate={setDate} setEvents={setEvents}/>
     <UILayout {...events} date={date}/>
    </>
  )
}

const UILayout = ({onDateChange, onButtonClick, date}) => (
  <>
      <input type="date" onChange={onDateChange} value={date} />
      <input type="button" onClick={onButtonClick}>Set Today</button>
  </>
);

// in a sidecar
// we are providing callbacks back to UI
const Sidecar = ({setDate, setEvents}) => {
  useEffect(() => setEvents({
      onDateChange:setDate,
      onButtonClick: () => setDate(Date.now),
  }), []);

  return null;
}

While in this example this looks a bit, you know, strange - there are 3 times more code that in the original example - that would make a sense for a real Calendar, especially if some helper library, like moment, has been used.

Example: Effect for react-remove-scroll - 300b UI, 2kb sidecar

Licence

MIT