DEV Community

Hiroto Shioi
Hiroto Shioi

Posted on

Introducing hiraku: A type-safe, imperative modal manager for shadcn/ui & Radix UI.

Managing modals in React can be a pain. You know the drill:

  1. Define useState in the parent component.
  2. Pass isOpen and onOpenChange down the tree.
  3. Create callback functions to handle results.
  4. Repeat for every single modal. ๐Ÿ˜ฉ

"I just want to call a function to open a modal, like alert() or confirm()."

If you've ever thought this, I built hiraku for you.

It's a new library designed specifically for shadcn/ui (Radix UI) to make modal management simple, type-safe, and imperative.

The Problem: "Declarative" isn't always better

shadcn/ui is amazing. But the standard way of handling dialogs often leads to bloated parent components.

The Old Way:

function ParentComponent() {
  // 1. State clutter
  const [isOpen, setIsOpen] = useState(false);

  // 2. Logic fragmentation
  const handleConfirm = () => {
    // ... logic ...
    setIsOpen(false);
  }

  return (
    <>
      <Button onClick={() => setIsOpen(true)}>Open</Button>
      <MyDialog 
        open={isOpen} 
        onOpenChange={setIsOpen} 
        onConfirm={handleConfirm} 
      />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

This separates the "trigger" logic from the "result" logic. It makes your code harder to read and maintain.

The Solution: Function-based Modals ๐Ÿš€

hiraku lets you treat modals like function calls.

The hiraku Way:

const handleDelete = async () => {
  // 1. Open it
  await confirmDialog.open({
    title: "Delete Item",
    message: "Are you sure?",
  });

  // 2. Await the result right here
  const { role } = await confirmDialog.onDidClose();

  if (role === "confirm") {
    console.log("Item deleted!");
  }
};
Enter fullscreen mode Exit fullscreen mode

Clean, linear, and easy to read.

Why hiraku? โœจ

  • โšก๏ธ Zero State Management: No more useState or isOpen props in your parents.
  • ๐ŸŽฏ Intuitive API: Just createDialog() and open().
  • ๐Ÿช„ Call from Anywhere: Open modals from hooks, event handlers, or even pure utility functions (like API error handlers).
  • ๐Ÿ”’ 100% Type-Safe: Props and return values are fully typed.
  • ๐Ÿงฉ shadcn/ui Ready: Designed to work seamlessly with your existing Radix UI components.
  • ๐Ÿชถ Lightweight: ~3KB (gzipped).

Quick Start

1. Install

npm install @hirotoshioi/hiraku
Enter fullscreen mode Exit fullscreen mode

Add the ModalProvider to your app root:

import { ModalProvider } from "@hirotoshioi/hiraku";

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <App />
    <ModalProvider />
  </StrictMode>
);
Enter fullscreen mode Exit fullscreen mode

2. Create a Controller

Wrap your component with createDialog. You don't need the <Dialog> wrapper anymore; hiraku handles it.

import { createDialog } from "@hirotoshioi/hiraku";

interface Props {
  title: string;
  message: string;
}

// Your component receives props directly
function InfoDialogContent({ title, message }: Props) {
  return (
    <DialogContent>
      <DialogHeader>
        <DialogTitle>{title}</DialogTitle>
        <DialogDescription>{message}</DialogDescription>
      </DialogHeader>
      <DialogFooter>
        {/* Close it via the controller */}
        <Button onClick={() => infoDialog.close()}>OK</Button>
      </DialogFooter>
    </DialogContent>
  );
}

// Create the controller
export const infoDialog = createDialog(InfoDialogContent);
Enter fullscreen mode Exit fullscreen mode

3. Open it!

// Fully typed props!
await infoDialog.open({
  title: "Hello World",
  message: "This is so much easier.",
});
Enter fullscreen mode Exit fullscreen mode

Getting Data Back (Type-Safe!)

You can even define what data the modal returns.

// Define the return type
export const editUserDialog = createDialog(EditUserDialog).returns<User>();

// In your component
const handleSave = (user: User) => {
  editUserDialog.close({ 
    role: "confirm", 
    data: user 
  });
};

// In your usage
const { data, role } = await editUserDialog.onDidClose();
if (role === "confirm" && data) {
  console.log("Updated user:", data);
}
Enter fullscreen mode Exit fullscreen mode

Call Modals from API Utilities

Since hiraku controllers aren't hooks, you can import them anywhere. This is a game-changer for global error handling.

// api.ts
import { errorDialog } from "./dialogs/errorDialog";

export async function fetchData() {
  try {
    // ... fetch logic
  } catch (err) {
    // Open a modal directly from your non-React logic!
    await errorDialog.open({
      title: "Network Error",
      message: "Something went wrong.",
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Migration is Easy

You don't have to rewrite your whole app. You can migrate one modal at a time. Just remove the isOpen props and wrap the component in createDialog.

Give it a try! ๐ŸŒŸ

I built hiraku to make my own life easier when working with shadcn/ui, and I hope it helps you too.

Check it out on GitHub and let me know what you think!

๐Ÿ‘‰ https://github.com/HirotoShioi/hiraku

(If you like it, a Star โญ๏ธ would make my day!)

Top comments (0)