KnisterPeter / tsdi

Dependency Injection container (IoC) for TypeScript
https://tsdi.js.org/
MIT License
52 stars 3 forks source link

Usage with react hooks (no more classes?) #392

Closed misantronic closed 10 months ago

misantronic commented 5 years ago

Some day, react hooks will take over and some people might try to live without using classes. Will tsdi make it in this world?

KnisterPeter commented 5 years ago

@misantronic There are plans for implementing injection with hooks. :smiley:

misantronic commented 5 years ago

Here is a little idea I had for using tsdi with hooks

const tsdi = new TSDI();

tsdi.enableComponentScanner();

function useTSDI<T = {}>(Store: { new (): T } | string, scope?: string): T {
    if (scope) {
        useTSDIScope(scope);
    }

    return React.useMemo(() => tsdi.get(Store), [Store]);
}

function useTSDIScope(scope: string): void {
    const tsdiScope = tsdi.getScope(scope);

    React.useEffect(() => {
        return () => tsdiScope.leave();
    }, []);

    React.useState(tsdiScope.enter());
}

function MyComponent() {
    const store = useTSDI(MyStore);

    ...
}
KnisterPeter commented 5 years ago

@misantronic That would be a first way but it requires to create the TSDI instance to be in the scope of the hook function. For a proof of concept it will be good enough. From an API perspective I would not tie the scope to the getter of an injection. This will not cover all use cases of scopes. I would suggest something like this:

function useTSDI<T>(dependency: { new (): T }): T {
    @external class Injector {
        @inject tsdi!: TSDI;
        public get(): T { return this.tsdi.get(dependency); }
    }
    return new Injector().get();
}
function useScope(scope: string): void {
    @external class Injector {
        @inject tsdi!: TSDI;
        public scope(): T { tsdi.getScope(scope); }
    }

    const injector = new Injector();

    React.useEffect(() => {
        injector.scope().enter();
        return () => injector.scope().leave();
    }, []);
}

Not sure if this is good enough right now. But you can see this as prototypical implementation and use it.

misantronic commented 5 years ago

Great proposal! I actually have some concerns in practice, entering the scope during the effect. that way you cannot write synchronous code when depending on a scope:

const store = useTSDI(StoreWithScope);
useScope('scope');

store.myMethod(); // will not work

So the scope would have to be entered beforehand:

...

React.useEffect(() => {
        return () => injector.scope().leave();
    }, []);

 injector.scope().enter();
misantronic commented 5 years ago
export function useTSDI<T>(Dependency: { new (): T }): T {
    return useMemo(
        () => {
            @external
            class Injector {
                @inject tsdi!: TSDI;
                public get(): T {
                    return this.tsdi.get(Dependency);
                }
            }

            return new Injector().get();
        },
        [Dependency]
    );
}

export function useScope(scope: string): void {
    const tsdiScope = useMemo(
        () => {
            @external
            class Injector {
                @inject private tsdi!: TSDI;
                public scope(): {
                    enter(): void;
                    leave(): void;
                } {
                    return this.tsdi.getScope(scope);
                }
            }

            const injector = new Injector();
            const injectorScope = injector.scope();

            injectorScope.enter();

            return injectorScope;
        },
        [scope]
    );

    useEffect(() => {
        return () => tsdiScope.leave();
    }, []);
}
KnisterPeter commented 5 years ago

@misantronic I'm not sure what you mean by write synchronous code when depending on a scope. The call order should be like this:

useScope('scope');
const store = useTSDI(StoreWithScope);
store.myMethod(); // will not work

I've worked with hooks until now but the useEffect hook should run while mounting a component and therefore I guess it will be executed right away. But that is an implmentation detail, it would work also by directly calling the enter method in a custom hook. It my result in problems in the future, since then you call enter on every render. If the implementation of scopes is changed to use e.g. reference counts this will break, because multiple calls to enter will only have one leave call during unmount. Therefore I recommended a different approach.

Another question I have is: Why do you use React.memo here? There is nothing to memorize. The hard work is done internally by TSDI and memorizing will result in strange side-effects for example if you have a non-singleton component in TSDI. Since the class/prototype you give to useTSDI is still the same you will get a stale component instead of a fresh one. As a baseline you should not cache TSDI managed instances.

KnisterPeter commented 5 years ago

@misantronic Here is a fully working example with react, tsdi and mobx:

import { observable } from "mobx";
import { observer } from "mobx-react";
import React from "react";
import { render } from "react-dom";
import { component, external, inject, TSDI } from "tsdi";

@component
class State {
  @observable
  public counter = 0;
}

const tsdi = new TSDI();
tsdi.enableComponentScanner();

function useTSDI<T>(dependency: { new (): T }): T {
  @external
  class Injector {
    @inject tsdi!: TSDI;
    public get(): T {
      return this.tsdi.get(dependency);
    }
  }
  return new Injector().get();
}

const App = observer(function App() {
  const state = useTSDI(State);
  return (
    <div>
      Hello World! {state.counter}
      <button onClick={() => state.counter++}>Click +</button>
      <button onClick={() => state.counter--}>Click -</button>
    </div>
  );
});

render(<App />, document.getElementById("app"));
KnisterPeter commented 5 years ago

Besides the working example above, I've noticed that mobx observer is not compatible with hooks since it creates a HOC as class component instead of a function component. Therefore in that case you may either need a HOC for the TSDI scope or you need something different for state management which can trigger rerenders. That may be useState but you probaby will have to deal with deep state changes (like above).

KnisterPeter commented 5 years ago

And as well this will not work:

useScope('scope');
const store = useTSDI(StoreWithScope);
store.myMethod(); // will not work

I have not check this but it may be, because the useEffect hooks mimics the componentDidMount and not the componentWillMount lifecycle. And in the code above the assumption was that the effect is called synchronous on the first render (which would be close to componentWillMount).

So I guess you probably need some HOC to enter and leave the scope.

misantronic commented 5 years ago

Besides the working example above, I've noticed that mobx observer is not compatible with hooks since it creates a HOC as class component instead of a function component. Therefore in that case you may either need a HOC for the TSDI scope or you need something different for state management which can trigger rerenders. That may be useState but you probaby will have to deal with deep state changes (like above).

So there is a lib mobx-react-lite which fully supports react-hooks. works great