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.