Magic Modal

repository·main·Indexed 20 days ago

https://github.com/gstj/react-native-magic-modal

A managed modal stack library for React Native and Web that treats modals as awaitable tasks. It allows developers to trigger modals from async flows and await typed results using a single root portal. The library supports Expo (iOS, Android, Web) and Next.js browser-only environments, providing a promise-based API via magicModal.show() and the useMagicModal() hook.

Tokens
23K
Snippets
81
Records
102
Agent score
67%

What's inside magic-modal

  1. Introduction to Magic Modal

    main

    Magic Modal is a library designed to turn modal interactions into typed, awaitable async results. Instead of managing complex state to track if a modal is open, you can await the result of a modal interaction directly within your business logic (events, effects, or services).

    Key features include:

    • One root portal: MagicModalPortal manages the modal stack.
    • Typed close results: show<T>() and hide(data) allow you to pass typed data from the modal back to the caller.
    • Readable async flows: You can await a modal, branch on its result, and immediately trigger the next step in a linear function.
    • Cross-platform: A single API for Web, iOS, and Android.
    const result = await magicModal.show<ConfirmationResult>(ConfirmationModal, {
      accessibilityLabel: "Confirm purchase",
    });
    
    if (
      result.reason === MagicModalHideReason.INTENTIONAL_HIDE &&
      result.data.confirmed
    ) {
      completePurchase();
    }
  2. Choose your runtime for Magic Modal

    main

    While the MagicModalPortal component remains the same across platforms, your integration method depends on your runtime:

    • Next.js and web: Add the portal to a Client Component. No special bundler configuration or React Native peer dependencies are required.
    • Expo: Use the same root and modal components across iOS, Android, and web.
    • iOS and Android: Requires specific handling for the Android back action and iOS native overlays.
  3. Overview of the Magic Modal public API

    main

    Magic Modal provides a minimal API surface designed to manage a stack of modals. The core functionality is distributed across four main pillars:

    1. MagicModalPortal: The single root component that must be mounted to own and render the modal stack.
    2. magicModal: A global object used to show and hide stack entries from anywhere in your application code.
    3. useMagicModal: A hook used to hide the current modal, specifically designed to return typed data back to the caller.
    4. MagicModalHideReason: An enum representing the reasons why a modal was closed (e.g., via user interaction or programmatic dismissal).
  4. Distinguish between data submission and cancellation using HideReturn

    main

    When calling show<T>(), the returned promise resolves to a HideReturn<T> object. To safely distinguish between a user submitting data and a user cancelling the modal (e.g., by tapping the backdrop or pressing the back button), you must check the reason property before accessing the data property.

    • Submission: If reason is MagicModalHideReason.INTENTIONAL_HIDE, the data property contains the submitted value of type T.
    • Cancellation: If the reason is any other value, the modal was dismissed without submitting data, and the data property will not be present/available for use.
    const result = await magicModal.show<FormValues>(FormModal, {
      accessibilityLabel: "Edit profile",
    });
    
    if (result.reason === MagicModalHideReason.INTENTIONAL_HIDE) {
      // Safely access result.data here
      await save(result.data);
    }
  5. Handle safe areas and keyboards in modal content

    main

    Magic Modal manages the overlay positioning, but the content inside the modal is responsible for its own layout constraints. To ensure a good user experience, you must manually include standard React Native primitives within your modal component:

    • SafeAreaView for notch/home indicator padding.
    • KeyboardAvoidingView for handling keyboard overlap.
    • ScrollView for content that exceeds device height.

    Best Practice: For long forms, disable modal swipe dismissal (swipeDirection: undefined) to prevent gesture conflicts with the internal ScrollView.

  6. Integrate Magic Modal in Next.js browser-only apps

    main

    When using Magic Modal in a Next.js or browser-only React environment, you do not need react-native, react-native-web, or any gesture/animation packages. The browser entry renders standard DOM elements and only requires react and react-dom.

    Key integration requirements:

    • The portal must be mounted within a Client Component.
    • In the web environment, the portal can be mounted directly without a gesture root view.
  7. How Magic Modal works and how to mount the portal

    main

    Magic Modal separates the dialog surface from the orchestration logic. The component you use in your UI renders the dialog surface, while MagicModalPortal manages the backdrop, stack, animations, and the promise lifecycle.

    To use Magic Modal, you must mount the MagicModalPortal exactly once at the root of your application (e.g., in your RootLayout).

    import { Stack } from "expo-router";
    import { GestureHandlerRootView } from "react-native-gesture-handler";
    import { MagicModalPortal } from "magic-modal";
    
    export default function RootLayout() {
      return (
        <GestureHandlerRootView style={{ flex: 1 }}>
          <Stack />
          <MagicModalPortal />
        </GestureHandlerRootView>
      );
    }
  8. Handle modal cancellation with MagicModalHideReason

    main

    When a modal is closed via a backdrop press or a swipe, the promise resolves with a reason but may not contain data. You must check the reason property against MagicModalHideReason.INTENTIONAL_HIDE to distinguish between a user-submitted action and a cancellation. This prevents attempting to access data that was never provided.

    if (result.reason === MagicModalHideReason.INTENTIONAL_HIDE) {
      submit(result.data);
    } else {
      trackCancellation(result.reason);
    }
  9. The Magic Modal mental model

    main

    To use Magic Modal correctly, understand the following lifecycle and relationship between its core components:

    1. MagicModalPortal: This component owns the modal stack. You should mount it once near your application root.
    2. magicModal.show: This method pushes a new entry onto the stack and returns an awaitable handle (which is itself a Promise).
    3. useMagicModal().hide: This hook provides the hide method used to close the current entry and pass typed data back to the caller.
    4. Resolution: The promise returned by show() resolves to a HideReturn<T> object, which contains both the submitted data and the reason the modal was closed.

    Note on Stacks: Every show() call creates an independent stack entry. Opening a second modal does not replace the first; it keeps the earlier entry and its associated promise in place.

  10. Understand magicModal.show() return values and reasons

    main

    The magicModal.show() method returns a handle that acts as a Promise. Awaiting this promise resolves to a result object.

    Resolution Reasons

    The promise resolves whenever the modal is closed. The reason property tells you how it was closed:

    • Intentional Hide: The modal was closed via the hide() function inside the component. In this case, data is populated with the values passed to hide().
    • System/External Dismissal: The modal was closed by a backdrop tap, swipe gesture, system-dismiss action (Android back button, web Escape, or native accessibility escape), or a call to hideAll(). In these cases, data is not provided.

    The Handle

    magicModal.show() returns a handle. While you can await it directly to get the result, you can also destructure it to access control methods while the modal is still open:

    • promise: An alias for the handle itself (the result of the interaction).
    • modalID: The unique identifier for the open modal.
    • update: A method to update the modal's props.
    • hide: A method to programmatically close the modal from the outside.
  11. How Magic Modal works

    main

    Magic Modal manages a modal stack through a single portal. The workflow follows these steps:

    1. Mount the Portal: Mount MagicModalPortal once at the root of your application.
    2. Show a Modal: Call magicModal.show() to push a new entry onto the stack. This returns an awaitable handle (a Promise).
    3. Handle Results: The handle resolves to a HideReturn<T> object, which contains the submitted data and the reason for closing.
    4. Close the Modal: Inside the modal component, use the useMagicModal() hook to call hide(data), which resolves the caller's promise.

    Each stack entry is independent, meaning multiple modals can be open simultaneously without mixing their results or configurations.

    const result = await magicModal.show<ConfirmationResult>(ConfirmationModal, {
      accessibilityLabel: "Confirm publish",
    });
    
    if (result.reason === MagicModalHideReason.INTENTIONAL_HIDE) {
      await publish(result.data);
    } else {
      recordCancellation(result.reason);
    }
  12. How to handle modal dismissal results

    main

    When you await magicModal.show<T>(...), the returned object contains a reason and potentially data.

    • Intentional Hides: If the component calls hide(data), the result reason is MagicModalHideReason.INTENTIONAL_HIDE. You should check this reason before accessing result.data to ensure type safety.
    • System/External Dismissals: Dismissals via backdrop press, swipe, Android Back, web Escape, or hideAll() resolve the promise with their own specific reasons and no data is provided.
    • Error Handling: Use try/catch blocks to handle any errors that occur during the asynchronous work following the modal result.