1.8.0 • Published 4 months ago

nextjs-app-hooks v1.8.0

Weekly downloads
-
License
MIT
Repository
-
Last release
4 months ago

NextJS App Hooks

A comprehensive library of React hooks for Next.js applications(app router compatible), designed to simplify state management and streamline component logic.

npm version npm downloads MIT License

Features

  • 📱 Full TypeScript support with comprehensive type definitions
  • 🔄 SSR compatible with proper hydration support for Next.js
  • 🎯 Focused utilities for common UI patterns and browser APIs
  • 🧩 Modular design allowing for tree-shaking to minimize bundle size
  • 📚 Comprehensive documentation with examples for each hook
  • Performance optimized implementations

Installation

# npm
npm install nextjs-app-hooks

# yarn
yarn add nextjs-app-hooks

# pnpm
pnpm add nextjs-app-hooks

Contributing

Contributions are welcome! Here's how you can help improve this library:

Setup

  1. Fork the repository
  2. Clone your fork: git clone https://github.com/your-username/nextjs-app-hooks.git
  3. Install dependencies: npm install
  4. Create a branch for your changes: git checkout -b feature/your-feature-name

Development

  • Run npm run dev to start the development build with watch mode
  • Follow the existing code style and TypeScript patterns
  • Add appropriate JSDoc comments and type definitions
  • Ensure your changes are properly typed and exports are added to index.ts

Testing

  • Write tests for your hooks in the __tests__ directory
  • Run tests with npm test or npm run test:watch
  • Ensure all tests pass before submitting a PR

Pull Requests

  1. Update documentation for any new hooks or changes to existing ones
  2. Make sure your code passes all tests and linting
  3. Submit a PR with a clear description of the changes and any relevant issue numbers
  4. Wait for review and address any feedback

Adding a New Hook

When adding a new hook, please ensure it:

  • Has a clear, focused purpose
  • Works with SSR and Next.js App Router
  • Includes comprehensive TypeScript definitions
  • Includes detailed JSDoc comments with examples
  • Is exported from the main index file
  • Follows the naming conventions of existing hooks

Thank you for helping improve nextjs-app-hooks!

Usage

All hooks are exported from the package root:

"use client";

import { useIsBrowser, useDarkMode, useLocalStorage } from "nextjs-app-hooks";

function MyComponent() {
  const isBrowser = useIsBrowser();
  const isDarkMode = useDarkMode();
  const { value, setValue } = useLocalStorage("user-settings", {
    defaultValue: { theme: "light" },
  });

  // Your component logic
}

Hooks Overview

Environment Detection

HookDescription
useIsBrowserDetect if code is running in browser or server environment
useIsServerDetect if code is running on the server
useHasRenderedTrack whether component has rendered at least once

Browser API Access

HookDescription
useBatteryAccess and monitor device battery status
useClipboardCopy text to clipboard with status tracking
useCookieManage browser cookies with advanced features
useGeolocationAccess and track device geolocation
useLocalStorageManage localStorage with SSR support
useSessionStorageManage sessionStorage with SSR support
useNetworkMonitor network connection status
usePermissionCheck and request browser permissions
usePreferredLanguageDetect and manage user's preferred language

UI and Interaction

HookDescription
useClickOutsideDetect clicks outside of a specified element
useDebounceCreate debounced functions and values
useHoverTrack hover state of an element
useIdleTrack user idle/active state
useIntersectionObserverTrack element visibility using Intersection Observer
useLockBodyScrollLock body scrolling for modals and drawers
useLongPressDetect long press gestures
useMediaQueryRespond to media queries with extensive options
useMouseTrack mouse position and state

Media Hooks

HookDescription
useDarkModeCheck if the screen is in dark mode
usePrefersReducedMotionCheck if the user prefers reduced motion
useOrientationCheck current screen orientation
useResponsiveConvenience hook for responsive design

Detailed API Documentation

Environment Detection

useIsBrowser

function useIsBrowser(): boolean;

Determines if the code is running in the browser or on the server.

Example:

"use client";

import { useIsBrowser } from "nextjs-app-hooks";

export default function MyComponent() {
  const isBrowser = useIsBrowser();

  useEffect(() => {
    if (isBrowser) {
      // Safe to use browser APIs like localStorage, window, etc.
      localStorage.setItem("visited", "true");
    }
  }, [isBrowser]);

  return (
    <div>
      {isBrowser ? "Browser APIs are available" : "Running on the server"}
    </div>
  );
}

useIsServer

function useIsServer(): boolean;

Similar to useIsBrowser but returns true when running on the server.

Example:

"use client";

import { useIsServer } from "nextjs-app-hooks";

export default function MyComponent() {
  const isServer = useIsServer();

  return <div>{isServer ? "Rendering on server" : "Rendering on client"}</div>;
}

useHasRendered

function useHasRendered(): boolean;

Returns false on first render and true afterward, allowing for logic that should only run after initial render.

Example:

"use client";

import { useHasRendered } from "nextjs-app-hooks";

export default function MyComponent() {
  const hasRendered = useHasRendered();

  return (
    <div>{!hasRendered ? "First render" : "Component has rendered before"}</div>
  );
}

Browser API Hooks

useBattery

function useBattery(): BatteryHookState;

Access and monitor the device's battery status.

Example:

"use client";

import { useBattery, formatBatteryTime } from "nextjs-app-hooks";

export default function BatteryStatus() {
  const { battery, isSupported, isLoading, error } = useBattery();

  if (!isSupported) return <p>Battery API is not supported on this device</p>;
  if (isLoading) return <p>Loading battery information...</p>;
  if (error) return <p>Error: {error.message}</p>;
  if (!battery) return <p>No battery information available</p>;

  return (
    <div>
      <h2>Battery Status</h2>
      <p>Level: {(battery.level * 100).toFixed(0)}%</p>
      <p>Charging: {battery.charging ? "Yes" : "No"}</p>
      {battery.charging ? (
        <p>Full in: {formatBatteryTime(battery.chargingTime)}</p>
      ) : (
        <p>Time remaining: {formatBatteryTime(battery.dischargingTime)}</p>
      )}
    </div>
  );
}

useClipboard

function useClipboard(options?: UseClipboardOptions): UseClipboardReturn;

Copy text to clipboard with status tracking.

Example:

"use client";

import { useClipboard } from "nextjs-app-hooks";

export default function CopyButton() {
  const { copyToClipboard, isSuccess, status } = useClipboard();

  return (
    <button
      onClick={() => copyToClipboard("Text to copy")}
      className={isSuccess ? "bg-green-500" : "bg-blue-500"}
    >
      {status === "idle" && "Copy to clipboard"}
      {status === "copied" && "Copied!"}
      {status === "error" && "Failed to copy"}
    </button>
  );
}

useCookie

function useCookie<T = string>(
  key: string,
  options?: UseCookieOptions<T>
): UseCookieReturn<T>;

Manage browser cookies with advanced features.

Example:

"use client";

import { useCookie } from "nextjs-app-hooks";

export default function CookieConsent() {
  const {
    value: consent,
    setCookie,
    removeCookie,
    hasValue,
  } = useCookie("cookie-consent");

  return (
    <div>
      {!hasValue && (
        <div className="cookie-banner">
          <p>We use cookies to enhance your experience.</p>
          <div>
            <button
              onClick={() =>
                setCookie("accepted", { maxAge: 60 * 60 * 24 * 365 })
              }
            >
              Accept
            </button>
            <button onClick={() => setCookie("declined")}>Decline</button>
          </div>
        </div>
      )}

      {hasValue && (
        <button onClick={removeCookie}>Reset Cookie Preferences</button>
      )}
    </div>
  );
}

useGeolocation

function useGeolocation(options?: UseGeolocationOptions): UseGeolocationReturn;

Access and track the device's geolocation.

Example:

"use client";

import { useGeolocation } from "nextjs-app-hooks";

export default function LocationComponent() {
  const { position, error, isLoading, getPosition, isSupported } =
    useGeolocation({ enableHighAccuracy: true });

  if (!isSupported) {
    return <p>Geolocation is not supported in your browser.</p>;
  }

  return (
    <div>
      <button onClick={getPosition} disabled={isLoading}>
        {isLoading ? "Getting location..." : "Get My Location"}
      </button>

      {position && (
        <div>
          <p>Latitude: {position.latitude}</p>
          <p>Longitude: {position.longitude}</p>
          <p>Accuracy: {position.accuracy} meters</p>
        </div>
      )}

      {error && <p>Error: {error.message}</p>}
    </div>
  );
}

useLocalStorage

function useLocalStorage<T>(
  key: string,
  options?: UseLocalStorageOptions<T>
): UseLocalStorageReturn<T>;

Manage localStorage with SSR support.

Example:

"use client";

import { useLocalStorage } from "nextjs-app-hooks";

export default function UserPreferences() {
  const { value: theme, setValue: setTheme } = useLocalStorage("theme", {
    defaultValue: "light",
  });

  return (
    <div>
      <p>Current theme: {theme}</p>
      <button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
        Toggle theme
      </button>
    </div>
  );
}

useSessionStorage

function useSessionStorage<T>(
  key: string,
  options?: UseSessionStorageOptions<T>
): UseSessionStorageReturn<T>;

Manage sessionStorage with SSR support.

Example:

"use client";

import { useSessionStorage } from "nextjs-app-hooks";

export default function FormWithSessionPersistence() {
  const {
    value: formData,
    setValue: setFormData,
    removeValue: clearForm,
  } = useSessionStorage("form_data", {
    defaultValue: { name: "", email: "", message: "" },
  });

  const handleChange = (e) => {
    const { name, value } = e.target;
    setFormData((prev) => ({ ...prev, [name]: value }));
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    // Submit form data
    console.log("Submitting:", formData);
    clearForm();
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        name="name"
        value={formData.name}
        onChange={handleChange}
        placeholder="Name"
      />
      <input
        name="email"
        value={formData.email}
        onChange={handleChange}
        placeholder="Email"
      />
      <textarea
        name="message"
        value={formData.message}
        onChange={handleChange}
        placeholder="Message"
      />
      <button type="submit">Submit</button>
    </form>
  );
}

useNetwork

function useNetwork(options?: UseNetworkOptions): UseNetworkReturn;

Monitor network connection status.

Example:

"use client";

import { useNetwork } from "nextjs-app-hooks";

export default function NetworkStatus() {
  const network = useNetwork({
    autoReconnect: true,
    onOffline: () => console.log("Connection lost"),
    onOnline: () => console.log("Connection restored"),
  });

  return (
    <div className={network.online ? "online" : "offline"}>
      <h1>Network Status</h1>
      <p>Status: {network.online ? "Online" : "Offline"}</p>
      <p>Connection: {network.connection.type}</p>
      <p>Speed: {network.connection.effectiveType}</p>

      {!network.online && (
        <button onClick={network.reconnect} disabled={network.reconnecting}>
          {network.reconnecting ? "Reconnecting..." : "Reconnect Manually"}
        </button>
      )}
    </div>
  );
}

usePermission

function usePermission(
  name: PermissionName,
  options?: UsePermissionOptions
): UsePermissionReturn;

Check and request browser permissions.

Example:

"use client";

import { usePermission } from "nextjs-app-hooks";

export default function CameraComponent() {
  const { state, isGranted, request, error } = usePermission("camera");

  return (
    <div>
      <p>Camera permission: {state}</p>

      {!isGranted && (
        <button onClick={request} disabled={state === "denied"}>
          {state === "denied"
            ? "Permission denied (check browser settings)"
            : "Request camera access"}
        </button>
      )}

      {isGranted && <div className="camera-view">Camera is available</div>}

      {error && <p className="error">Error: {error.message}</p>}
    </div>
  );
}

usePreferredLanguage

function usePreferredLanguage(
  options?: UsePreferredLanguageOptions
): UsePreferredLanguageReturn;

Detect and manage user's preferred language.

Example:

"use client";

import { usePreferredLanguage } from "nextjs-app-hooks";

export default function LanguageSwitcher() {
  const { language, setLanguage, resetToSystemDefault } = usePreferredLanguage({
    supportedLanguages: ["en", "fr", "es", "de"],
    onChange: (lang) => console.log(`Language changed to ${lang}`),
  });

  const languages = [
    { code: "en", name: "English" },
    { code: "fr", name: "Français" },
    { code: "es", name: "Español" },
    { code: "de", name: "Deutsch" },
  ];

  return (
    <div className="language-switcher">
      <select value={language} onChange={(e) => setLanguage(e.target.value)}>
        {languages.map((lang) => (
          <option key={lang.code} value={lang.code}>
            {lang.name}
          </option>
        ))}
      </select>
      <button onClick={resetToSystemDefault}>Reset to System Default</button>
    </div>
  );
}

UI and Interaction Hooks

useClickOutside

function useClickOutside<T extends HTMLElement = HTMLElement>(
  onClickOutside: (event: MouseEvent | TouchEvent) => void,
  options?: UseClickOutsideOptions
): RefObject<T | null>;

Detect clicks outside of a specified element.

Example:

"use client";

import { useClickOutside } from "nextjs-app-hooks";

export default function Modal({ isOpen, onClose, children }) {
  const modalRef = useClickOutside(() => {
    if (isOpen) onClose();
  });

  if (!isOpen) return null;

  return (
    <div className="modal-overlay">
      <div ref={modalRef} className="modal-content">
        {children}
        <button onClick={onClose}>Close</button>
      </div>
    </div>
  );
}

useDebounce

function useDebounce<T extends (...args: any[]) => any>(
  fn: T,
  options?: UseDebounceOptions
): [
  (...args: Parameters<T>) => void,
  () => void,
  boolean,
  () => ReturnType<T> | undefined
];

function useDebounceValue<T>(
  value: T,
  delay?: number,
  options?: Omit<UseDebounceOptions, "delay">
): T;

Create debounced functions and values.

Example:

"use client";

import { useDebounce, useDebounceValue } from "nextjs-app-hooks";

export default function SearchInput() {
  const [searchTerm, setSearchTerm] = useState("");
  const debouncedSearchTerm = useDebounceValue(searchTerm, 300);

  // Use debounced value for API calls
  useEffect(() => {
    if (debouncedSearchTerm) {
      searchAPI(debouncedSearchTerm);
    }
  }, [debouncedSearchTerm]);

  // Using debounced function
  const searchAPI = async (term) => {
    // API call logic
    console.log("Searching for:", term);
  };

  const [debouncedSearch, cancelSearch, isPending] = useDebounce(searchAPI, {
    delay: 300,
    trackPending: true,
  });

  const handleInputChange = (e) => {
    const value = e.target.value;
    setSearchTerm(value);
    debouncedSearch(value);
  };

  return (
    <div>
      <input
        type="text"
        value={searchTerm}
        onChange={handleInputChange}
        placeholder="Search..."
      />
      {isPending && <span>Searching...</span>}
    </div>
  );
}

useHover

function useHover<T extends HTMLElement = HTMLDivElement>(
  options?: UseHoverOptions
): UseHoverReturn<T>;

Track hover state of an element.

Example:

"use client";

import { useHover } from "nextjs-app-hooks";

export default function HoverCard() {
  const { hoverRef, isHovered } =
    useHover <
    HTMLDivElement >
    {
      enterDelay: 100,
      leaveDelay: 300,
    };

  return (
    <div ref={hoverRef} className={`card ${isHovered ? "card-hovered" : ""}`}>
      Hover over me!
      {isHovered && <div className="tooltip">Hello there!</div>}
    </div>
  );
}

useIdle

function useIdle(options?: UseIdleOptions): UseIdleReturn;

Track user idle/active state.

Example:

"use client";

import { useIdle } from "nextjs-app-hooks";

export default function IdleDetectionExample() {
  const { isIdle, reset, idleTime, remainingTime } = useIdle({
    idleTime: 60000, // 1 minute
    onIdle: () => console.log("User is idle"),
    onActive: () => console.log("User is active"),
  });

  return (
    <div>
      <p>User is currently {isIdle ? "idle" : "active"}</p>
      <p>Time since last activity: {Math.floor(idleTime / 1000)}s</p>
      <p>Time until idle: {Math.ceil(remainingTime / 1000)}s</p>

      {isIdle && <button onClick={reset}>I'm still here!</button>}
    </div>
  );
}

useIntersectionObserver

function useIntersectionObserver<T extends Element = HTMLDivElement>(
  options?: UseIntersectionObserverOptions
): UseIntersectionObserverReturn<T>;

// Simplified version
function useInView<T extends Element = HTMLDivElement>(
  options?: UseInViewOptions
): UseInViewReturn<T>;

Track element visibility using the Intersection Observer API.

Example:

"use client";

import { useInView } from "nextjs-app-hooks";

export default function LazyImage() {
  const { ref, inView } = useInView({
    threshold: 0.1,
    triggerOnce: true,
  });

  return (
    <div ref={ref} className="image-container">
      {inView ? (
        <img src="https://example.com/image.jpg" alt="Lazy loaded" />
      ) : (
        <div className="placeholder" />
      )}
    </div>
  );
}

useLockBodyScroll

function useLockBodyScroll(
  initialLocked?: boolean,
  options?: UseLockBodyScrollOptions
): UseLockBodyScrollReturn;

Lock body scrolling for modals, drawers, etc.

Example:

"use client";

import { useLockBodyScroll } from "nextjs-app-hooks";

export default function Modal({ isOpen, onClose, children }) {
  // Lock scrolling when modal is open
  useLockBodyScroll(isOpen);

  if (!isOpen) return null;

  return (
    <div className="modal-overlay">
      <div className="modal-content">
        {children}
        <button onClick={onClose}>Close</button>
      </div>
    </div>
  );
}

useLongPress

function useLongPress(
  callback?: ((e: React.MouseEvent | React.TouchEvent) => void) | null,
  options?: UseLongPressOptions
): UseLongPressReturn;

Detect long press gestures.

Example:

"use client";

import { useLongPress } from "nextjs-app-hooks";

export default function LongPressButton() {
  const onLongPress = () => alert("Long pressed!");

  const { handlers, isLongPressed } = useLongPress(onLongPress, {
    delay: 500,
    onPressStart: () => console.log("Press started"),
    onLongPressEnd: () => console.log("Long press ended"),
  });

  return (
    <button {...handlers} className={isLongPressed ? "active" : ""}>
      Press and hold me
    </button>
  );
}

useMediaQuery

function useMediaQuery(
  features: MediaQueryFeatures | string,
  options?: UseMediaQueryOptions
): UseMediaQueryReturn;

Respond to media queries with extensive options.

Example:

"use client";

import { useMediaQuery, useDarkMode } from "nextjs-app-hooks";

export default function ResponsiveComponent() {
  const isMobile = useMediaQuery({ maxWidth: "sm" });
  const isTablet = useMediaQuery({ minWidth: "md", maxWidth: "lg" });
  const isDesktop = useMediaQuery({ minWidth: "xl" });

  // Detect dark mode
  const isDarkMode = useDarkMode();

  return (
    <div className={isDarkMode ? "dark-theme" : "light-theme"}>
      {isMobile.matches && <MobileView />}
      {isTablet.matches && <TabletView />}
      {isDesktop.matches && <DesktopView />}
    </div>
  );
}

useMouse

function useMouse(options?: UseMouseOptions): UseMouseReturn;

Track mouse position and state.

Example:

"use client";

import { useMouse } from "nextjs-app-hooks";

export default function MouseTracker() {
  const mouse = useMouse();

  return (
    <div style={{ height: "300px", border: "1px solid black" }}>
      <p>
        Mouse position: {mouse.position.x}, {mouse.position.y}
      </p>
      <p>Left button: {mouse.buttons.left ? "Pressed" : "Released"}</p>
      <p>Inside element: {mouse.isInside ? "Yes" : "No"}</p>
    </div>
  );
}

Advanced Examples

Theme Switcher with Persistence

"use client";

import { useState, useEffect } from "react";
import { useDarkMode, useLocalStorage, useMediaQuery } from "nextjs-app-hooks";

export default function ThemeSwitcher() {
  // Get OS theme preference
  const systemPrefersDark = useDarkMode();

  // Load theme from localStorage
  const { value: savedTheme, setValue: saveTheme } = useLocalStorage("theme", {
    defaultValue: "system",
  });

  // Derived theme state
  const [theme, setTheme] = useState(savedTheme);
  const [isDark, setIsDark] = useState(false);

  // Update the active theme based on system preference and user selection
  useEffect(() => {
    const isDarkTheme =
      theme === "dark" || (theme === "system" && systemPrefersDark);

    setIsDark(isDarkTheme);

    // Update document class for CSS theme switching
    if (isDarkTheme) {
      document.documentElement.classList.add("dark");
    } else {
      document.documentElement.classList.remove("dark");
    }
  }, [theme, systemPrefersDark]);

  // Handle theme changes
  const handleThemeChange = (newTheme) => {
    setTheme(newTheme);
    saveTheme(newTheme);
  };

  return (
    <div className="theme-switcher">
      <h2>Theme Settings</h2>

      <div className="theme-options">
        <button
          onClick={() => handleThemeChange("light")}
          className={theme === "light" ? "active" : ""}
        >
          Light
        </button>

        <button
          onClick={() => handleThemeChange("dark")}
          className={theme === "dark" ? "active" : ""}
        >
          Dark
        </button>

        <button
          onClick={() => handleThemeChange("system")}
          className={theme === "system" ? "active" : ""}
        >
          System ({systemPrefersDark ? "Dark" : "Light"})
        </button>
      </div>

      <div className="current-theme">
        Currently using: <strong>{isDark ? "Dark" : "Light"}</strong> theme
      </div>
    </div>
  );
}

Form with Session Persistence and Validation

"use client";

import { useState } from "react";
import { useSessionStorage, useNetwork } from "nextjs-app-hooks";

export default function PersistentForm() {
  const {
    value: formData,
    setValue: setFormData,
    removeValue: clearForm,
  } = useSessionStorage("contact_form", {
    defaultValue: {
      name: "",
      email: "",
      message: "",
    },
  });

  const [errors, setErrors] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [isSuccess, setIsSuccess] = useState(false);

  const network = useNetwork();

  const validateForm = () => {
    const newErrors = {};

    if (!formData.name.trim()) {
      newErrors.name = "Name is required";
    }

    if (!formData.email.trim()) {
      newErrors.email = "Email is required";
    } else if (!/\S+@\S+\.\S+/.test(formData.email)) {
      newErrors.email = "Email is invalid";
    }

    if (!formData.message.trim()) {
      newErrors.message = "Message is required";
    }

    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };

  const handleChange = (e) => {
    const { name, value } = e.target;
    setFormData((prev) => ({ ...prev, [name]: value }));

    // Clear error when user starts typing
    if (errors[name]) {
      setErrors((prev) => ({ ...prev, [name]: "" }));
    }
  };

  const handleSubmit = async (e) => {
    e.preventDefault();

    if (!validateForm()) {
      return;
    }

    if (!network.online) {
      alert(
        "You are offline. Please try again when you have internet connection."
      );
      return;
    }

    setIsSubmitting(true);

    try {
      // Simulate API call
      await new Promise((resolve) => setTimeout(resolve, 1500));

      // Success
      setIsSuccess(true);
      clearForm();
    } catch (error) {
      console.error("Error submitting form:", error);
      alert("Failed to submit the form. Please try again.");
    } finally {
      setIsSubmitting(false);
    }
  };

  if (isSuccess) {
    return (
      <div className="success-message">
        <h2>Thank you for your message!</h2>
        <p>We'll get back to you soon.</p>
        <button onClick={() => setIsSuccess(false)}>
          Send another message
        </button>
      </div>
    );
  }

  return (
    <form onSubmit={handleSubmit} className="contact-form">
      <div className="form-group">
        <label htmlFor="name">Name</label>
        <input
          id="name"
          name="name"
          type="text"
          value={formData.name}
          onChange={handleChange}
          className={errors.name ? "error" : ""}
        />
        {errors.name && <div className="error-message">{errors.name}</div>}
      </div>

      <div className="form-group">
        <label htmlFor="email">Email</label>
        <input
          id="email"
          name="email"
          type="email"
          value={formData.email}
          onChange={handleChange}
          className={errors.email ? "error" : ""}
        />
        {errors.email && <div className="error-message">{errors.email}</div>}
      </div>

      <div className="form-group">
        <label htmlFor="message">Message</label>
        <textarea
          id="message"
          name="message"
          value={formData.message}
          onChange={handleChange}
          className={errors.message ? "error" : ""}
          rows={4}
        />
        {errors.message && (
          <div className="error-message">{errors.message}</div>
        )}
      </div>

      <div className="form-actions">
        <button type="submit" className="submit-button" disabled={isSubmitting}>
          {isSubmitting ? "Sending..." : "Send Message"}
        </button>

        <button
          type="button"
          className="clear-button"
          onClick={clearForm}
          disabled={isSubmitting}
        >
          Clear Form
        </button>
      </div>

      {!network.online && (
        <div className="network-warning">
          You appear to be offline. The form will be saved but cannot be
          submitted until you're back online.
        </div>
      )}
    </form>
  );
}
1.8.0

4 months ago

1.7.0

4 months ago

1.6.1

4 months ago

1.5.0

4 months ago

1.4.0

5 months ago

1.3.0

5 months ago

1.2.0

5 months ago

1.0.0

5 months ago