useToast.ts 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. import { create } from "zustand"
  2. import { useEffect, useCallback, useRef } from "react"
  3. /**
  4. * Toast message types for different visual styles
  5. */
  6. export type ToastType = "info" | "success" | "warning" | "error"
  7. /**
  8. * A single toast message in the queue
  9. */
  10. export interface Toast {
  11. id: string
  12. message: string
  13. type: ToastType
  14. /** Duration in milliseconds before auto-dismiss (default: 3000) */
  15. duration: number
  16. /** Timestamp when the toast was created */
  17. createdAt: number
  18. }
  19. /**
  20. * Toast queue store state
  21. */
  22. interface ToastState {
  23. /** Queue of active toasts (FIFO - first one is displayed) */
  24. toasts: Toast[]
  25. /** Add a toast to the queue */
  26. addToast: (message: string, type?: ToastType, duration?: number) => string
  27. /** Remove a specific toast by ID */
  28. removeToast: (id: string) => void
  29. /** Clear all toasts */
  30. clearToasts: () => void
  31. }
  32. /**
  33. * Default toast duration in milliseconds
  34. */
  35. const DEFAULT_DURATION = 3000
  36. /**
  37. * Generate a unique ID for toasts
  38. */
  39. let toastIdCounter = 0
  40. function generateToastId(): string {
  41. return `toast-${Date.now()}-${++toastIdCounter}`
  42. }
  43. /**
  44. * Zustand store for toast queue management
  45. */
  46. export const useToastStore = create<ToastState>((set) => ({
  47. toasts: [],
  48. addToast: (message: string, type: ToastType = "info", duration: number = DEFAULT_DURATION) => {
  49. const id = generateToastId()
  50. const toast: Toast = {
  51. id,
  52. message,
  53. type,
  54. duration,
  55. createdAt: Date.now(),
  56. }
  57. // Replace any existing toasts - new toast shows immediately
  58. // This provides better UX as users see the most recent message right away
  59. set(() => ({
  60. toasts: [toast],
  61. }))
  62. return id
  63. },
  64. removeToast: (id: string) => {
  65. set((state) => ({
  66. toasts: state.toasts.filter((t) => t.id !== id),
  67. }))
  68. },
  69. clearToasts: () => {
  70. set({ toasts: [] })
  71. },
  72. }))
  73. /**
  74. * Hook for displaying and managing toasts with auto-expiry.
  75. * Returns the current toast (if any) and utility functions.
  76. *
  77. * The hook handles auto-dismissal of toasts after their duration expires.
  78. */
  79. export function useToast() {
  80. const { toasts, addToast, removeToast, clearToasts } = useToastStore()
  81. // Track active timers for cleanup
  82. const timersRef = useRef<Map<string, NodeJS.Timeout>>(new Map())
  83. // Get the current toast to display (first in queue)
  84. const currentToast = toasts.length > 0 ? toasts[0] : null
  85. // Set up auto-dismissal timer for current toast
  86. useEffect(() => {
  87. if (!currentToast) {
  88. return
  89. }
  90. // Check if timer already exists for this toast
  91. if (timersRef.current.has(currentToast.id)) {
  92. return
  93. }
  94. // Calculate remaining time (accounts for time already elapsed)
  95. const elapsed = Date.now() - currentToast.createdAt
  96. const remainingTime = Math.max(0, currentToast.duration - elapsed)
  97. const timer = setTimeout(() => {
  98. removeToast(currentToast.id)
  99. timersRef.current.delete(currentToast.id)
  100. }, remainingTime)
  101. timersRef.current.set(currentToast.id, timer)
  102. return () => {
  103. // Clean up timer if toast is removed before expiry
  104. const existingTimer = timersRef.current.get(currentToast.id)
  105. if (existingTimer) {
  106. clearTimeout(existingTimer)
  107. timersRef.current.delete(currentToast.id)
  108. }
  109. }
  110. }, [currentToast?.id, currentToast?.createdAt, currentToast?.duration, removeToast])
  111. // Cleanup all timers on unmount
  112. useEffect(() => {
  113. return () => {
  114. timersRef.current.forEach((timer) => clearTimeout(timer))
  115. timersRef.current.clear()
  116. }
  117. }, [])
  118. // Convenience methods for different toast types
  119. const showToast = useCallback(
  120. (message: string, type?: ToastType, duration?: number) => {
  121. return addToast(message, type, duration)
  122. },
  123. [addToast],
  124. )
  125. const showInfo = useCallback(
  126. (message: string, duration?: number) => {
  127. return addToast(message, "info", duration)
  128. },
  129. [addToast],
  130. )
  131. const showSuccess = useCallback(
  132. (message: string, duration?: number) => {
  133. return addToast(message, "success", duration)
  134. },
  135. [addToast],
  136. )
  137. const showWarning = useCallback(
  138. (message: string, duration?: number) => {
  139. return addToast(message, "warning", duration)
  140. },
  141. [addToast],
  142. )
  143. const showError = useCallback(
  144. (message: string, duration?: number) => {
  145. return addToast(message, "error", duration)
  146. },
  147. [addToast],
  148. )
  149. return {
  150. /** Current toast being displayed (first in queue) */
  151. currentToast,
  152. /** All toasts in the queue */
  153. toasts,
  154. /** Generic toast display method */
  155. showToast,
  156. /** Show an info toast */
  157. showInfo,
  158. /** Show a success toast */
  159. showSuccess,
  160. /** Show a warning toast */
  161. showWarning,
  162. /** Show an error toast */
  163. showError,
  164. /** Remove a specific toast by ID */
  165. removeToast,
  166. /** Clear all toasts */
  167. clearToasts,
  168. }
  169. }