Giter Site home page Giter Site logo

ko-react's Introduction

ko-react

npm install @retsam/ko-react

A library for allowing Knockout observables to be used with React components. Knockout's observable system is very similar to MobX, so in practice this is very much like using mobx-react.

This intended as a migration path for legacy Knockout codebases - the knockout html template engine can be replaced with React templates, while leaving the core Knockout logic intact, allowing for an incremental migration path to React.

This library provides utilities for allowing React components to rerender, driven by observables (like MobX), and a bindingHandler to bridge from ko templates to react components. There is preliminary support for the reverse - react components to knockout logic - in the form of the useKnockoutBindings hook.

API

Hooks

useComputed

A hook version of ko.pureComputed, wraps a function, returns the value of evaluating the function, and recomputes the function whenever any observables that are read by the function change.

interface FullNameProps {
    firstName: KnockoutObservable<string>,
    lastName: KnockoutObservable<string>
}

// Re-renders if either firstName or lastName change
const Greeter = ({firstName, lastName}: FullNameProps) => useComputed(() => (
    <span>
        Hello, {firstName()} {lastName()}
    </span>
), [firstName, lastName]); // see below

Takes a second "dependencies" argument that follows the same rules as React's useMemo or useCallback hooks - the computed function is replaced and recalculated whenever one of the dep inside the array changes. In the above example, the computed body itself will detect changes to the firstName and lastName observables, while the [firstName, lastName] argument will detect if firstName observable is replaced with a different observable entirely.

You can configure the "react-hooks/exhaustive-deps" linter rule to also check this hook:

{
    "react-hooks/exhaustive-deps": [
        "warn", { "additionalHooks": "useComputed" }
    ]
}

See the "Patterns" section for the different ways to use this hook, and a comparison to useObservable.

useObservable

Reads and subscribes to an observable - if the observable's value changes the component re-renders:

interface FullNameProps {
    firstName: KnockoutObservable<string>,
    lastName: KnockoutObservable<string>
}

// Re-renders only if firstName changes
const Greeter = ({firstName, lastName}: FullNameProps) => {
    const fName = useObservable(firstName)
    return (
        <span>
            Hello, {fName} {lastName()}
        </span>
    );
}

See the "Patterns" section for a comparison to useComputed.

useSubscription

Sets up a subscription to an observable (or any subscribable) - runs the provided callback whenever the observable emits a new value, without triggering a rerender (unless the callback modifies state). Disposes the subscription when the component is unmounted.

type PageTitleComponentProps = {
    text: KnockoutObservable<string>,
    prefix: string,
}
const PageTitleComponent = ({}) => {
    const [count, setCount] = useState(0);

    useSubscription(text, newText => {
        // count will always be the latest value, no need for a `deps` array.
        document.title = `${count} - ${newText}`
    });

    return <button onClick={() => setCount(count + 1)}>Click</button>;
}

Knockout bindingHandler reactComponent

Used to host a react tree inside a Knockout app, useful for incrementally migrating from knockout templates to React components.

<div data-bind="
    reactComponent: {
        Component: MyComponent,
        props: {prop: 'propValue'}
    }
"><!-- MyComponent will render here --></div>

Must be registered in ko.bindingHandlers, can be done by calling the exported register function.

Shorthand syntax

If registerShorthandSyntax is called, knockout preprocessNode logic will be registered which allows the previous example to be written as:

<!-- react: MyComponent {
    prop: 'propValue'
} -->

This will insert a div and render MyComponent inside it.

React to Knockout

While the majority of this library is aimed at hosting React trees inside of Knockout, the reverse may be useful (primarily for migration purposes), so a few utilities are provided for that purpose:

🚧 useKnockoutBindings 🚧

This hook takes an element ref and a set of knockout bindings and applies those bindings to the element.

const MessageComponent = ({text}: {text: string}) => {
    const elementRef = useRef<HTMLDivElement>(null);

    const viewModel = { name: text };
    useKnockoutBindings(elementRef, viewModel);

    return (
        // Ref of the element where knockout bindings will be applied
        <div ref={elementRef}>
            // Standard knockout data-binding
            Hello, <span data-bind="text: name" />
        </div>
    );
};

⚠ Caveats:

  • This hook assumes that the ref is stable: if the ref is pointed from one element to a different the bindings won't be reapplied to the new element.

  • Currently no mechanism for setting knockout context, in a Knockout -> React -> Knockout situation, the inner knockout tree won't have access to the outer knockout tree's context. Consider applying the let binding if this is necessary.

  • There's some danger here about React and Knockout both trying to control the same elements: it's likely safest to not use this hook directly, but to use the provided KnockoutTemplate component, which wraps this hook to provide a React version of the template bindingHandler.

KnockoutTemplate

A React component which takes a knockout template and data as props, and renders that template inside a

. Currently the safest way to host knockout content inside a React tree.

const KnockoutGreeter = ({firstName, lastName}) => (
    <KnockoutTemplate
        name="knockoutGreeterTemplate"
        data={{firstName, lastName}}
    />
);

⚠️ NOTE: the same caveat about context from useKnockoutBindings applies here.

Higher Order Component - observe

A Higher Order Component which wraps a component such that any observables that are read during the render function will cause the component to rerender.

interface FullNameProps {
    firstName: KnockoutObservable<string>,
    lastName: KnockoutObservable<string>
}

// Re-renders if either firstName or lastName change
const Greeter = observe(({firstName, lastName}: FullNameProps) => (
    <span>
        Hello, {firstName()} {lastName()}
    </span>
));

The implementation details of observe however, are somewhat ugly, and it should be considered deprecated in favor of the hooks API.

Patterns

Broadly there seem to be two strategies in using this library, one is to use a very broad useComputed that wraps the entire JSX return:

const Greeter = ({ personVm }: GreeterProps) => {
    return useComputed(() => (
        <span>
            Hello, {personVm.firstName()} {personVm.lastName()}

            <img src={personVm.avatarIcon()} />
        </span>
    ), [personVm]);
}

In this approach useObservable is basically not used.

A downside of this approach is that it can be awkward to mix with native React state and hooks - anything that gets used in the JSX ends up declared in the deps array which can get long. The above example is able to mitigate this with a viewModel that can have many observable properties on it. (This assumes the properties themselves are readonly and personVm.firstName will not be replaced with a different observable).


The second is a more granular useObservable-oriented approach;

const Greeter = ({ personVm }: GreeterProps ) => {
    const firstName = useObservable(personVm.firstName);
    const lastName = useObservable(personVm.lastName);
    const avatarIcon = useObservable(personVm.avatarIcon);

    return (
        <span>
            Hello, {firstName} {lastName}

            <img src={avatarIcon} />
        </span>
    );
};

In this approach useComputed can still be used for individual parts - for example the above example could calculate a fullName computed - and is particularly useful for calling functions that aren't directly observable but rely on observables.

The downside of this approach is that it can be easy to accidentally consume observable state in a way that isn't wrapped in either useObservable or useComputed which will result in a stale view because the component won't rerender. An eslint rule has been written to try to catch these cases, but it's difficult to completely avoid false positives or false negatives.

ko-react's People

Contributors

dependabot[bot] avatar kmckee avatar retsam avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar

ko-react's Issues

Render Knockout component inside React

Hi @Retsam,

Since you are quite familiar with the React/Knockout scenario, can you think of a way to render a Knockout component (and not just a template) inside React?

I first tried the naivé approach of customizing your KnockoutTemplate.tsx to use Knockout component binding:

KnockoutComponent.tsx

import React, { useRef } from 'react';
import useKnockoutBindings from '../hooks/useKnockoutBinding';

export interface KnockoutComponentProps {
  name: string;
  params?: unknown;
}

/**
 * Renders a Knockout component inside a React component.
 *
 * Create or use an existing Knockout component, register it with a name (ko.components.register),
 * and use that name as a prop when using this component:
 * <KnockoutComponent name="my-knockout-component" />
 *
 * You can also provide params that will be sent to the Knockout component constructor,
 * just as any other Knockout component. You should be able to give it observables, callbacks etc.
 *
 * <KnockoutComponent name="my-knockout-component" params={{ someParam: 'test', someCallback: () => { console.info('42'); } }} />
 *
 * @returns A React component with a Knockout component baby!
 */
const KnockoutComponent = ({ name, params = {} }: KnockoutComponentProps) => {
  const elRef = useRef<HTMLDivElement>(null);

  useKnockoutBindings(
    {
      name,
      params,
    },
    elRef
  );

  return (
    <div
      data-bind="
        component: {
          name: name,
          params: params
        }
      "
      ref={elRef}
    />
  );
};

export default React.memo(KnockoutComponent);

However, when updating the props/params, React of course (like it is design to do) unmounts and mounts (i.e. recreates) the DOM element that holds the Knockout component, which results in the Knockout component constructor being run again etc. so a bit difficult to keep state.

Do you have any ideas on how to accomplish this?

Here is an example dummy Knockout component:

TestComp.html

<div>
    <div data-bind="text: counter"></div>
    <button data-bind="click: onClick">Test me</button>
</div>

TestComp.ts

import View from "./TestComp.html";

interface TestCompProps {
    counter: number;
    someCallback: () => void;
}

class TestComp {
    callback: () => void;
    counter: number;

    constructor(params: TestCompProps) {
        console.info('TestComp constructor');

        if (params.someCallback)
            this.callback = params.someCallback;
        this.counter = params.counter;
    }

    onClick = () => {
        console.info('Knockout: Click. Trying to call the callback');
        if (this.callback) {
            this.callback();
        } else {
            console.warn('Callback not provided');
        }
    }

    dispose() {
        console.info('TestComp disposed');
    }
}

export = {
    viewModel: TestComp,
    template: View
};

Which is used like this:

...
const [counter, setCounter] = useState<number>(0);
const someCallback = useCallback(() => {
  setCounter((counter) => counter + 1);
}, []);
...

return (
...
<KnockoutComponent
  name="test-comp"
  params={{
    counter,
    someCallback,
  }}
/>
...
);

`useComputed` hook doesn't work well with closure variables

Currently the useComputed hook can capture local variables by closure, but can't react to those variables changing in subsequent renders.

For example:

interface AdderProps {
    x: KnockoutObservable<number>;
}
// Bug: doesn't update when y changes, and `y` is always 0 inside the callback
const Adder = ({x}: AdderProps) => ({
    const [y, setY] = useState(0);
    return useComputed(() => (
        <div onClick={() => setY(y + 1)}>
            {x()} + {y} = {x() + y}
        </div>
    ));
});

The likely solution here is a second argument to useComputed, with the same mechanics as useEffect.

require componenet dependencies

is it possible to use amd require as a loader for the react component and call it from the binding handler, something like this:

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.