Announcing ic-use-actor – A React Hook to simplify communicating with canisters

Hey! I built a React hook that simplifies interacting with canisters. You feed the context provider with an identity and some info about your canister and then get access to typed canister methods you can use in your components. Also, request/response interceptors! Nothing super fancy but quite useful.

Try it and let me know what you think :point_down::point_down::point_down:

Features

  • Shared Actor Context: Allows the same actor to be used across multiple components.
  • Typescript Support: Makes full use of the canister service definitions to provide type safety for requests and responses.
  • Interceptors: onRequest, onResponse, onRequestError, and onResponseError callbacks allow for intercepting and processing requests and responses.

Pre-requisites

ic-use-actor needs an Internet Computer (IC) identity to work. The examples below uses ic-use-siwe-identity as an identity provider. You can use any other identity provider as long as it returns a valid IC identity.

Installation

In addition to ic-use-actor, the following packages are required:

  • @dfinity/agent
  • @dfinity/identity
  • @dfinity/candid
npm install ic-use-actor @dfinity/agent @dfinity/identity @dfinity/candid

Usage

To use ic-use-actor in your React application, follow these steps:

1. Setting Up the Actor Context and Hook

First, create an actor context and a corresponding hook for each IC canister you would like to access. Export the hook to be able to use it in your components. The hook returned by createUseActorHook can be named anything you want. If using ic-use-actor with multiple canisters, you might want to name the hook after the canister to make it easier to identify which hook is for which canister - for example, useMyCanister, useMyOtherCanister, etc.

import {
  createActorContext,
  createUseActorHook,
} from "ic-use-actor";
import { _SERVICE } from "path-to/your-service.did";

const actorContext = createActorContext<_SERVICE>();
export const useActor = createUseActorHook<_SERVICE>(actorContext);

2. Creating an Actor Provider Component

Create one or more ActorProvider components to provide access to your canisters. ActorProviders can be nested to provide access to multiple canisters.

// Actors.tsx

import { ReactNode } from "react";
import {
  ActorProvider,
  createActorContext,
  createUseActorHook,
} from "ic-use-actor";
import {
  canisterId,
  idlFactory,
} from "path-to/your-service/index";
import { _SERVICE } from "path-to/your-service.did";
import { useSiweIdentity } from "ic-use-siwe-identity";

const actorContext = createActorContext<_SERVICE>();
export const useActor = createUseActorHook<_SERVICE>(actorContext);

export default function Actors({ children }: { children: ReactNode }) {
  const { identity } = useSiweIdentity();

  return (
    <ActorProvider<_SERVICE>
      canisterId={canisterId}
      context={actorContext}
      identity={identity}
      idlFactory={idlFactory}
    >
      {children}
    </ActorProvider>
  );
}

3. Wrapping Your Application

Wrap your application root component with the ActorProvider component(s) you created in the previous step to provide access to your canisters.

// App.tsx

import Actors from "./Actors";

function App() {
  return (
    <Actors>
      <MyApplication />
    </Actors>
  );
}

4. Accessing the Actor in Components

In your components, use the useActor hook to access the actor:

// AnyComponent.tsx

import { useActor } from "path-to/useActor";

function AnyComponent() {
  const { actor } = useActor();

  // Use the actor for calling methods on your canister
  React.useEffect(() => {
    actor
      .my_method()
      .then((result) => {
        // Do something with the result
      })
      .catch((error) => {
        // Handle the error
      });
  }, []);
}

Advanced Usage

Setting up interceptors

Interceptors can be used to intercept requests and responses. You can use them to modify requests, log requests and responses, or perform other actions.

export default function Actor({ children }: { children: ReactNode }) {
  const { identity } = useSiweIdentity();

  const handleRequest = (data: InterceptorRequestData) => {
    // Do something
    // data: { args: unknown[], methodName: string }
    return data.args;
  };

  const handleResponse = (data: InterceptorResponseData) => {
    // Do something
    // data: { args: unknown[], methodName: string, response: unknown }
    return data.response;
  };

  const handleError = (data: InterceptorErrorData) => {
    // Do something
    // data: { args: unknown[], methodName: string, error: unknown }
    return data.error;
  };

  return (
    <ActorProvider<_SERVICE>
      canisterId={canisterId}
      context={actorContext}
      identity={identity}
      idlFactory={idlFactory}
      onRequest={handleRequest}
      onRequestError={handleError}
      onResponse={handleResponse}
      onResponseError={handleError}
    >
      {children}
    </ActorProvider>
  );
}
7 Likes

Thanks a lot for the ic-use-actor and ic-use-internet-identity packages! This is really useful and I’m using both.

Using react, I’m trying to query data on page load:

export default function Home() {
    ...
    const { actor } = useActor();
    const { identity } = useInternetIdentity();
    ...
  
    // Load data from the backend when an identity is available
    useEffect(() => {
        actor.getSomeData().then((result) => { ... });
        ...
    }

However, actor appears to be defined only when the user is logged in. Do you have any idea why?

In other words, the above code triggers the error TypeError: Cannot read properties of undefined (reading 'getSomeData').

whereas if I add an if like so:

// Load data from the backend when an identity is available
    useEffect(() => {
        if (actor) {
            actor.getSomeData().then((result) => { ... });
        }
        ...
    }

…then it:

  • does nothing if the user is not logged in (actor seems to never become defined)
  • works as expected (it fetches data with getSomeData) if the user is logged in

I’m really curious why actor appears to become defined only when the user is logged in with II. Is it because of this line? If so, how can I change this constraint. Basically I just want users to be able to read pages with data loaded from the my backend canister, whether they are logged in or not! :stuck_out_tongue:

Cheers!

1 Like

Hey, thanks for asking! You are right, the actor does not get initialised unless there is an identity, see this line:

If I get this right, you would like to use the actor returned by useActor() independently of if the user is logged in or not?

(For calls where identity don’t matter, there is always the option of using the predefined actor that is exported from index.js of the declarations folder of the backend. But no hook then, and no request/response interceptors.)

I could remove that identity check, allowing the actor to be “upgraded” once an identity is available. But perhaps that is no good, thinking out loud now. Then you wouldn’t have a clear way as a developer to choose if you want an authenticated or not authenticated actor.

Another option would be to return an anonymousActor in addition to actor, to allow full flexibility for the developer.

I need to think a little bit on this.

That would be pretty awesome and fwiw it is the way I expected it to work :stuck_out_tongue:

I think I will go with the second idea, letting the actor and the anonymousActor remain separate but both accessible from the hook. I think it is good the developer have to be specific on wanting authentication or not. And if you want the upgrading behaviour, you can add a small check yourself, something like:

function Sunshine() {
  const { identity } = useInternetIdentity()
  const { actor, anonymousActor } = useActor();

  useEffect(() => {
    let sunshine;
    if (identity) {
      sunshine = await actor.getAuthorizedSunshine();
    } else {
      sunshine = await anonymousActor.getAnonSunshine();
    }
    ...
1 Like

would you provide an example on using multiple actors ?

Hey, using multiple actors don’t require any special consideration, other than making sure names don’t collide. Just create two actor providers etc.

Renaming the hook returned by createUseActorHook should be enough.

// ActorsService1.tsx
export const useService1Actor = createUseActorHook<_SERVICE>(actorContext);
// ActorsService2.tsx
export const useService2Actor = createUseActorHook<_SERVICE>(actorContext);