Skip to Content
Piplup

react-acl vs CASL

Compare CASL and @piplup/react-acl for React permissions: permission strings versus conditional rules, loading states, typing, server checks and when to use each.

CASL and @piplup/react-acl both decide what a React app shows each user, with a Can component and a hook. They model access differently, and that difference decides which one fits.

  • CASL checks an action on a subject, and a rule can carry conditions on the subject's fields: "may update a Post whose authorId is mine".
  • @piplup/react-acl checks permission names such as post:update, with : wildcards and roles. It doesn't look inside records.

This page compares @casl/ability 7.1 and @casl/react 7.1 with @piplup/react-acl 4.1, as of November 2026.

At a glance

CASL@piplup/react-acl
ModelActions on subjects, with optional conditions and fieldsPermission strings and roles
Record-level rules ("only their own posts")Yes, MongoDB-style conditionsNo; combine a check with your own comparison
Field-level rulesYesNo
Deny rulesYes, cannot, with a reasonNo; access is only granted
Wildcardsmanage for any action, all for any subject*, and post:* for everything under post
RolesYour code turns roles into rulesChecked directly with hasRole and Can role
Loading state in CanNone; no rules means deniedundefined principal renders the loading slot
TypingGenerics on the ability typeModule augmentation of permission and role names
What the server sendsRules as JSON{ roles, permissions } as JSON
Database query helpers@casl/prisma, @casl/mongooseNone
React Server ComponentsUse @casl/ability in server codeCheck functions work from 4.1.0
Dependencies@casl/ability uses the @ucast packagesNone
Other frameworksVue, Angular, Aurelia packagesReact only

The same check in both

Hide a Delete button from users who can't delete posts. With @piplup/react-acl:

import { AccessControlProvider, Can } from "@piplup/react-acl";

const principal = { roles: ["editor"], permissions: ["post:read", "post:delete"] };

<AccessControlProvider principal={principal}>
  <Can permission="post:delete">
    <DeleteButton />
  </Can>
</AccessControlProvider>;

With CASL:

import { createMongoAbility } from "@casl/ability";
import { AbilityProvider, Can } from "@casl/react";

const ability = createMongoAbility([
  { action: "read", subject: "Post" },
  { action: "delete", subject: "Post" },
]);

<AbilityProvider value={ability}>
  <Can I="delete" a="Post">
    <DeleteButton />
  </Can>
</AbilityProvider>;

For a yes-or-no check like this one, the two are about equally short.

When the rule depends on the record

Now editors may delete only their own posts. CASL puts the condition in the rule and checks the post itself:

import { AbilityBuilder, createMongoAbility, subject } from "@casl/ability";
import { Can } from "@casl/react";

const { can, build } = new AbilityBuilder(createMongoAbility);
can("delete", "Post", { authorId: user.id });
const ability = build();

<Can I="delete" this={subject("Post", post)}>
  <DeleteButton />
</Can>;

@piplup/react-acl has no conditions, so the record comparison is your code. A separate permission name describes the narrower grant:

import { useAccessControl } from "@piplup/react-acl";

function PostActions({ post, userId }: { post: Post; userId: string }) {
  const { can } = useAccessControl();
  const mayDelete = can("post:delete") || (can("post:delete:own") && post.authorId === userId);

  return mayDelete ? <DeleteButton /> : null;
}

That's fine for a few rules like this. When many rules depend on fields of the record, CASL keeps them in one place, and your server can apply the same rules to database queries with @casl/prisma or @casl/mongoose.

While access is loading

Permissions usually arrive from an API after the first render.

CASL's Can has no loading state. An ability with no rules denies everything, so content wrapped in Can stays hidden and any not fallback shows until the rules arrive. Keep your own loading flag if users shouldn't see a denial first.

@piplup/react-acl treats an undefined principal as "not loaded yet". Its Can renders a separate loading slot instead of fallback, and useAccessControl returns loading: true:

<Can permission="post:publish" loading={<ButtonSkeleton />} fallback={<RequestReviewButton />}>
  <PublishButton />
</Can>

See Not loaded, versus no access.

Typing

CASL is typed through the ability's generics, which list the actions and subjects:

import type { MongoAbility } from "@casl/ability";

type AppAbility = MongoAbility<["read" | "update" | "delete", "Post" | "Comment"]>;

You pass AppAbility wherever you create or read the ability, such as useAbility<AppAbility>().

@piplup/react-acl is typed by augmenting the package once. After that, Can, can() and the check functions accept only your names, with no type argument at each call:

src/types/react-acl.d.ts
declare module "@piplup/react-acl" {
  interface PermissionsDef {
    "post:read": never;
    "post:delete": never;
    "post:delete:own": never;
  }
}

See Typed permissions.

Which should you choose?

Choose CASL if:

  • Rules depend on the record: owners, teams, statuses, dates.
  • You need field-level rules, or explicit deny rules with reasons.
  • You want the same rules to filter database queries.
  • You also use Vue or Angular.

Choose @piplup/react-acl if:

  • Your backend already speaks in permission names, such as OAuth scopes or a roles table that expands to permissions.
  • You want post:* style wildcards without listing every action.
  • You want a loading state in Can and useAccessControl without extra code.
  • You want a small package with no dependencies.

Whichever you choose, the UI check only hides things. The server still has to enforce the same rules on every request.

FAQ

What is the difference between CASL and @piplup/react-acl?

CASL checks actions on subjects and can attach conditions to a rule, such as "update a Post whose authorId is the user's id", so it answers questions about specific records. @piplup/react-acl checks flat permission strings such as post:update, with : wildcards and roles, and answers what a user may do in general. It's smaller and has a built-in loading state, but it has no conditions.

Can @piplup/react-acl check permissions on a specific record?

Not by itself. It checks permission names, not record fields. For "only their own posts" you combine a permission check with your own comparison, such as can("post:update:own") && post.authorId === userId, or let your server send a per-record flag. If many rules depend on record fields, CASL fits better.

Does CASL have a loading state in React?

No. An ability with no rules yet denies everything, so Can renders nothing until rules arrive. Track loading yourself if you need a placeholder. @piplup/react-acl treats an undefined principal as loading, and its Can renders a loading slot for it.

Can I use both together?

Yes, but each needs its own provider and checks, so most apps pick one. Use @piplup/react-acl when permission names describe your access model, and CASL when rules depend on the record being acted on.

Which one works on the server?

Both. CASL's @casl/ability runs anywhere and has @casl/prisma and @casl/mongoose to turn rules into database queries. @piplup/react-acl's check functions are plain functions that work in Node servers, and from 4.1.0 in React Server Components, server actions and route handlers.

See also

On this page