useConnect hook

Feature Card

Description

Allow the use of a hook to retrieve state, actions and libraries.

User Stories

As a Frontity theme developer
I want to use a hook to get state, actions and libraries
so that I can keep my component props clean

Examples

import { useConnect } from "frontity";

const MyComp = () => {
  const { state, actions, libraries } = useConnect();
  return ( /* ... */ );
}

Possible solution

In TypeScript it will need to know the type of the current Package.

import { useConnect } from "frontity";

const MyComp: React.FC = () => {
  const { state, actions, libraries } = useConnect<MyPackage>();
  return ( /* ... */ );
}

This is a tougher problem than I thought because with the current implementation there’s nothing to subscribe to, the component must be wrapped in a computation. With hooks, you don’t have the opportunity to wrap the component.

The guys from Mobx solve it using this approach:

function LogoutWidget() {
  const { user } = useStore();
  return useObserver(() => (
    <Link to="/logout">
      <span className="name">{user.name}</span>
    </Link>
  ));
};

I don’t quite like it because you need to use two hooks instead of one. Apart from that, I guess that if you access user.name before useObserver it won’t re-render automatically. For example, this won’t work!

function LogoutWidget() {
  const { user } = useStore();
  const name = user.name;

  return useObserver(() => (
    <Link to="/logout">
      <span className="name">{name}</span>
    </Link>
  ));
};

We can solve it wrapping the component with connect:

const MyComp = connect(() => {
  const { state } = useConnect();
  return (
    <span>{state.user.name}</span>
  );
});

or

const MyComp = () => {
  const { state } = useConnect();
  return (
    <span>{state.user.name}</span>
  );
};

export default connect(MyComp);

But it feels kind of strange to have to use both connect and useConnect. Apart from that, who does connect knows that it doesn’t have to inject state, actions and libraries? We’d have to introduce another API or tell connect somehow:

export default connect(MyComp, false); // Pass false to avoid injection
export default connectHook(MyComp); // Alternative API.

Not very elegant.


On the other hand, if we want to make useConnect work on its own, we need to isolate the state we pass to it using a unique proxy for each component. That’s something we want to add in the next version of Frontity Connect so we can explore that.

My 2 cents:
Yeah, I don’t really like the idea of both wrapping the component with connect and using the hook because it will be confusing for the user to have 2 APIs. I think that wrapping the return in useObserver is probably the least bad option, but I would argue if the API is poor, we shouldn’t give the option to use hooks “just because”. I think it’s better to have just one clear way to use connect as a HOC.

I think that if the naming was connect for the wrapper of the component, used like it’s being used now, and useStore for the hook, it wouldn’t be so terrible, but for the part where you are importing both from the same package and adds boilerplate :confused:

I find useConnect confusing in any case as the natural way to call this would be useStore.

1 Like

I don’t know why I didn’t thought about this in the first place, but of course it is possible to make it work by accepting a mapState function:

const MyComp = () => {
  const name = useConnect(({ state }) => state.user.name);
  return (
    <span>{name}</span>
  );
};

That solution could work today.

And you can of course return as many things as you want and even compute things.

const MyComp = () => {
  const { fullname, avatar } = useConnect(({ state }) => ({
    fullname: `${state.user.name} {state.user.surname}`,
    avatar: state.user.avatar
  });
  return (
    <>
      <span>{fullname}</span>
      <img src={avatar} />
    </>
  );
};

TypeScript would work like this:

const name = useConnect<MyPackage>(({ state }) => state.user.name);

You can also create hooks and reuse them:

const useFullName = () => useConnect(({ state }) => `${state.user.name} {state.user.surname}`);
const useAvatar = () => useConnect(({ state }) => state.user.avatar); 

const MyComp = () => {
  const fullname = useFullName();
  const useAvatar = useAvatar();
  return (
    <>
      <span>{fullname}</span>
      <img src={avatar} />
    </>
  );
};

And we could add shallow compare to the returned value/object to avoid some unnecessary rerenders.

3 Likes

Wow, I think I really like this approach. It would make sense to have a collection of hooks in order to simplify the access to the state, and you wouldn’t need to wrap components or anything, just use them wherever you want!

Apart from that, useConnect or useStore? I prefer useConnect but maybe because of the name of the library. Do you think useStore would be a better name?

I’d go with useConnect for consistency, yes.

And I would not make custom hooks at first, because I think that when people is learning Frontity they need to understand how it works and how things are organized.

If they see something like this:

import { useConnect } from "frontity";

const link = useConnect(({ state }) => state.router.link);

it’s much more clear that:

  • The link is coming from the router.
  • It is stored in state.router.link.
  • They can use useConnect to get things from the state.

than if you show them something like this:

import { useLink } from "@frontity/hooks";

const link = useLink();

Just chiming in to say great idea @luisherranz :slightly_smiling_face:

About this;

I’m also against making more specialized “frontity” hooks. I think this should belong in the userspace. We should show examples in the documentation on how to use useConnect to make custom hooks though!

1 Like

I have just realized that these two approaches are compatible and we could support both.

Pass a mapState function

In this case, only the mapState function is reactive. This works fine:

const isReady = useConnect(({ state }) => state.source.get(state.router.link).isReady)

But this doesn’t:

const state = useConnect(({ state }) => state);
const isReady = state.source.get(state.router.link).isReady;

So we would have to be careful teaching that because it is not intuitive.

Don’t pass a mapState function

This implementation will be possible once we improve Connect internals. Right now, Connect works with a single proxy and for this to work we need to create a different proxy for each React component that uses this hook.

The good thing about this implementation is that the whole React component is reactive, so there’s no way to break it. This will always work:

const { state } = useConnect();
const isReady = state.source.get(state.router.link).isReady);

We could implement the first one now and the second one once we improve Connect internals, or just choose one of them.

I have finally realized that we cannot do this:

const Comp = () => {
  const { state, actions, libraries } = useConnect();
  // ...
};

export default Comp;

even if we add reproxification to Connect, because we are doing memo in connect and that is an important optimization.

So in the end, the recommendation would be to do this:

const Comp = () => {
  const { state, actions, libraries } = useConnect();
  // ...
};

export default memo(Comp);

which doesn’t make sense because if we recommend wrapping the component, we can recommend to wrap it in connect and avoid the complexity of reproxification.

const Comp = () => {
  const { state, actions, libraries } = useConnect();
  // ...
};

export default connect(Comp);

And this hook is something we can do today with basically a line of code.


We could also do the mapState implementation:

const Comp = () => {
  const { link } = useConnect(({ state }) => state.router.link);
  // ...
};

export default Comp;

but I think it can lead to confusion. For example, this component won’t be reactive to changes of link and it is not trivial to know why:

const Comp = () => {
  const { router } = useConnect(({ state }) => state.router);
  return <div>{router.link}</div>;
};

export default Comp;

And again, we would have to recommend using memo anyway, so why bother.


Finally, one of the problems that this hook solves is to avoid passing state, actions and libraries as props to a children component, like this:

const CompWrapper = ({ state, someProp, ...restOfTheProps }) => {
  // Do something with state and someProps.

  // Comp will receive `actions` and `libraries`.
  return <Comp {...restOfTheProps} />;
};

export default connect(CompWrapper);

For that to work now, you have to extract also actions and libraries like this:

const CompWrapper = ({
  state,
  actions,
  libraries,
  someProp,
  ...restOfTheProps
}) => {
  // Do something with state and someProps.

  // Comp won't receive anything but ...restOfTheProps.
  return <Comp {...restOfTheProps} />;
};

export default connect(CompWrapper);

We can solve it using the useConnect hook, but we need to prevent connect from injecting the props. My proposal is to add a second argument to connect for options and add an injectProps boolean, like this:

const CompWrapper = ({ someProp, ...restOfTheProps }) => {
  const { state } = useConnect();
  // Do something with state and someProps.

  // Comp won't receive anything but ...restOfTheProps.
  https: return <Comp {...restOfTheProps} />;
};

export default connect(CompWrapper, { injectProps: false });

Most of the times this won’t be necessary as this is only an edge case.

This has been released and we can find more info at our docs. @luisherranz or @David, as you were the reviewers of the Pull Request, could you do a quick recap here please?

Sure @SantosGuillamot :slightly_smiling_face:


Implementation

This hook was finally developed by @orballo in this pull request.

The implementation didn’t vary from what was proposed here.

The most important thing to notice is that the hook still requires the use of connect:

import { connect, useConnect } from "frontity";

const Comp = () => {
  const { state, actions, libraries } = useConnect();

  return (
    // ...
  );
};

export default connect(Comp);

Use cases

There are two clear use cases for the hook:

  • When you need to pass props down to an HTML node.

In this case, you also need to use the new { injectProps: false } option of connect to avoid the injection of the state, actions and libraries options.

import { connect, useConnect } from "frontity";

const Input = (props) => {
  const { state, actions, libraries } = useConnect();

  return <input {...props} />;
};

export default connect(Input, { injectProps: false });
  • When developing other hooks that need access to state, actions or libraries.

For example, a hook that retrieves the current post needs to access state. Without the useConnect hook it would be like this:

const useCurrentPost = (state) => {
  const data = state.source.get(state.router.link);
  return state.source[data.type][data.id];
};

And the component needs to get state and pass it to the hook.

import { useCurrentPost } from "./my-hooks";

const Comp = ({ state }) => {
  const data = useCurrentPost(state);
};

export default connect(Comp);

Thanks to the useConnect hook, we can avoid the need to pass state to this type of hoks:

const useCurrentPost = () => {
  const { state } = useConnect();
  const data = state.source.get(state.router.link);
  return state.source[data.type][data.id];
};

The component can just use the hook without worrying about state.

import { useCurrentPost } from "./my-hooks";

// More simple component.
const Comp = () => {
  const data = useCurrentPost();
};

export default connect(Comp);

The component still needs to be connected.

TypeScript

The usage of TypeScript is slightly simplified because you don’t have to merge the props with state, actions and libraries.

These are the types for a normal connected component:

import { connect } from "frontity";
import { Connect } from "frontity/types";
import { Packages } from "../../types";

const Comp: React.FC<Connect<Packages, { otherProp: string }>> = ({
  state,
  otherProp,
}) => {
  // Component logic.
};

export default connect(Comp);

And these are the types for useConnect:

import { connect, useConnect } from "frontity";
import { Packages } from "../../types";

const Comp: React.FC<{ otherProp: string }>> = ({
  otherProp,
}) => {
  const { state } = useConnect<Packages>();
  // Component logic.
};

export default connect(Comp);

We don’t plan to support other implementations for the moment.