React

Date: 2025-02-14

Hooks

import { DependencyList, useEffect, useState } from "react";
import { debounceTime, fromEvent, map, Observable, Subject } from "rxjs";

export interface IRect {
    width: number;
    height: number;
}

export interface IData<T> {
    data: T | undefined;
    revision: number;
    loading: boolean;
    loaded: boolean;
    initialLoading: boolean;
    setData: (data: T) => unknown,
    updateData: () => unknown
}

export function useEffectAsync(func: () => unknown, deps?: DependencyList) {
    useEffect(() => {
        func();
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, deps);
}

export interface IUseDataState<T> {
    data: T | undefined;
    trigger: number;
    revision: number;
    loading: boolean;
    loaded: boolean;
    initialLoading: boolean;
}

export function useData<T>(fetchFn: () => Promise<T>, deps: any = []): IData<T> {

    const [state, setState] = useObjectState<IUseDataState<T>>({
        revision: 0,
        trigger: 0,
        loaded: false,
        loading: true,
        initialLoading: true,
        data: undefined
    });

    useEffectAsync(async () => {
        if (!state.initialLoading) {
            setState({ loading: true, loaded: false });
        }
        let newState: Partial<IUseDataState<T>> = {};
        try {
            const newData = await fetchFn();
            newState = { data: newData, loaded: true, revision: state.revision + 1 };
        } catch (e) {
            console.error(e);
            newState = { data: undefined, revision: state.revision + 1 };
        } finally {
            setState({ ...newState, loading: false, initialLoading: false });
        }
    }, [...deps, state.trigger]);

    return {
        data: state.data,
        revision: state.revision,
        loading: state.loading,
        loaded: state.loaded,
        initialLoading: state.initialLoading,
        setData: (data: T) => setState({ data: data, revision: state.revision + 1 }),
        updateData: () => setState({ trigger: state.trigger + 1 })
    } as IData<T>;
}


export function useIsMobile(): boolean {
    const bp = 768;
    const [isMobile, setIsMobile] = useState(window.innerWidth < bp);
    useEffect(() => {
        const updateSize = (windowSize: number): void => {
            setIsMobile(windowSize < bp);
        };

        const resizeEvent = fromEvent(window, "resize");
        resizeEvent.pipe(
            map(x => (x.target as Window).innerWidth),
            debounceTime(500)
        ).subscribe(updateSize);
    }, []);
    return isMobile;
}

export function useObservable<T>(subject: Observable<T | undefined>, initialValue?: T): T | undefined {
    const [data, setData] = useState<T | undefined>(initialValue);
    useEffect(() => {
        if (!subject) { return; }
        const subscription = subject.subscribe((x: any) => {
            setData(x);
        });
        return () => subscription.unsubscribe();
    }, [subject]);
    return data;
}

export function useElementSize(ref: React.RefObject<HTMLElement>) {
    const [size, setSize] = useState<IRect>({ height: 0, width: 0 });
    useEffect(() => {
        const element = ref.current;
        if (!element) return;
        const onSizeChanged = () => {
            if (!element) return;
            const rect = element.getBoundingClientRect();
            setSize(rect);
        };
        const observer = new (window as any).ResizeObserver(onSizeChanged);
        observer.observe(element);
        return () => observer.unobserve(element);
    }, [ref]);
    return size;
}


export function useObjectState<T>(initialValue: T): [T, (c: Partial<T>) => any] {
    const [item, setItem] = useState<T>(initialValue);
    const handleChange = (changes: Partial<T>) => setItem(existing => ({ ...existing, ...changes }));
    return [item, handleChange];
}

export function useStateDebounce<T>(time: number, initialValue: T): [T, (v: T) => any] {
    const [value, setValue] = useState<T>(initialValue)
    const [values] = useState(() => new Subject<T>())
    useEffect(() => {
        const sub = values.pipe(debounceTime(time)).subscribe(setValue)
        return () => sub.unsubscribe()
    }, [time, values])
    return [value, (v: T) => values.next(v)];
}


Components

import React from "react";

export function isNullOrEmpty(text: string | undefined) {
    if (text === undefined || text === null || String(text).trim() === "")
        return true;
    return false;
}

export function TextWithLines(props: { text: string | undefined }) {
    const { text } = props;
    const lines = String(text ?? "").replace(/\r\n(?![.!?]|\s*[A-Z])/g, " ");
    return lines.split(/\r\n/).map((line, index) => (
        <React.Fragment key={index}>
            {line.trim()}
            <br />
        </React.Fragment>
    ));
}

type AutoTextWithLinesProps = {
    text: string | undefined
} & React.HTMLAttributes<HTMLDivElement>; // 👈 alle standaard div-props

export function AutoTextWithLines({ text, ...restOfProps }: AutoTextWithLinesProps) {
    if (isNullOrEmpty(text)) return <></>;
    return <div {...restOfProps}><TextWithLines text={text} /></div>;
}
// -----

import { Property } from "csstype";
import React, { ReactNode } from "react";

export function Align(props: { h?: Property.JustifyItems; v?: Property.AlignItems; children: ReactNode; }) {
    let style: React.CSSProperties = {};

    if (props.h || props.v)
        style = { ...style, display: "grid" };

    if (props.h) {
        style = { ...style, width: "100%", justifyItems: props.h };
    }

    if (props.v) {
        style = { ...style, height: "100%", alignItems: props.v };
    }
    return <div style={style}>
        {props.children}
    </div>;
}


/**
 * Custom hook om een queryparameter in de hash (#) van de URL te lezen en te schrijven.
 * Werkt met hash-routing zoals:  /#/stock/by-location?location=1234
 * werkt url bij, maar triggert geen refresh
 */
export function useHashSearchParam(paramName: string) {
    const getCurrentValue = useCallback(() => {
        const [, queryPart] = window.location.hash.split("?");
        return new URLSearchParams(queryPart ?? "").get(paramName) ?? "";
    }, [paramName]);

    const [value, setValue] = useState<string>(getCurrentValue);

    // Update lokale state als gebruiker via back/forward navigeert
    useEffect(() => {
        const handler = () => setValue(getCurrentValue());
        window.addEventListener("hashchange", handler);
        return () => window.removeEventListener("hashchange", handler);
    }, [getCurrentValue]);

    // Update hash zonder herlaad/navigatie
    const setHashParam = useCallback(
        (newValue?: string) => {
            const hash = window.location.hash || "";
            const [pathPart, queryPart] = hash.split("?");
            const params = new URLSearchParams(queryPart ?? "");

            if (newValue) params.set(paramName, newValue);
            else params.delete(paramName);

            const newHash =
                params.toString().length > 0
                    ? `${pathPart}?${params.toString()}`
                    : pathPart;

            window.history.replaceState({}, "", `${window.location.pathname}${newHash}`);
            setValue(newValue ?? "");
        },
        [paramName]
    );

    return [value, setHashParam] as const;
}

NPM packages

linq-to-typescript (https://www.npmjs.com/package/linq-to-typescript)
react-icons  (https://react-icons.github.io/react-icons/)
antd (https://ant.design/)


framer-motion
dayjs
d3
date-fns
copy-to-clipboard
rxjs
shortid
nanoid
jwt-decode

Forms

import create from 'zustand';

const useFormStore = create((set) => ({
  formData: {},
  setField: (key: string, value: any) =>
    set((state) => ({ formData: { ...state.formData, [key]: value } })),
}));


const FormField = ({ value, onChange }) => {
  return <input value={value || ''} onChange={(e) => onChange(e.target.value)} />;
};

const App = () => {
  const { formData, setField } = useFormStore();

  return (
    <div>
      <h3>Flexibele Formulier Velden</h3>
      <label>Naam:</label>
      <FormField value={formData.naam} onChange={(val) => setField('naam', val)} />

      <label>Email:</label>
      <FormField value={formData.email} onChange={(val) => setField('email', val)} />

      <button onClick={() => console.log('Submit:', formData)}>Verzenden</button>
    </div>
  );
};

export default App;

import create from 'zustand';

interface IArticleFields {
  title: string;
  description: string;
  price: number;
}


interface FormState {
  formData: Partial<IArticleFields>; // Partial zodat velden optioneel zijn
  setField: <K extends keyof IArticleFields>(key: K, value: IArticleFields[K]) => void;
}

const useFormStore = create<FormState>((set) => ({
  formData: {},
  setField: (key, value) =>
    set((state) => ({
      formData: { ...state.formData, [key]: value },
    })),
}));

const App = () => {
  const { formData, setField } = useFormStore();

  return (
    <div>
      <h3>Artikel Formulier</h3>

      <label>Titel:</label>
      <FormField value={formData.title} onChange={(val) => setField("title", val)} />

      <label>Beschrijving:</label>
      <FormField value={formData.description} onChange={(val) => setField("description", val)} />

      <label>Prijs:</label>
      <FormField
        value={formData.price ? formData.price.toString() : ""}
        onChange={(val) => setField("price", Number(val))}
      />

      <button onClick={() => console.log("Submit:", formData)}>Verzenden</button>
    </div>
  );
};

import { ReactElement } from "react";
import { useFormField } from "./useFormField";

//const [value, setValue] = useState(() => formStore.getValues()[field]);

interface FormFieldProps {
  field: keyof IArticleFields;
  children: ReactElement<{ value: any; onChange: (val: any) => void }>;
}

const FormField = ({ field, children }: FormFieldProps) => {
  const [value, setValue] = useFormField(field);

  return React.cloneElement(children, {
    value,
    onChange: (e: any) => setValue(e.target ? e.target.value : e),
  });
};

export default FormField;






import { BehaviorSubject } from "rxjs";

export class FormStore<T extends Record<string, any>> {
  private subjects: { [K in keyof T]: BehaviorSubject<T[K]> };

  constructor(initialValues: T) {
    this.subjects = Object.keys(initialValues).reduce((acc, key) => {
      acc[key as keyof T] = new BehaviorSubject(initialValues[key as keyof T]);
      return acc;
    }, {} as { [K in keyof T]: BehaviorSubject<T[K]> });
  }

  getField$ = <K extends keyof T>(key: K) => this.subjects[key].asObservable();
  setField = <K extends keyof T>(key: K, value: T[K]) => this.subjects[key].next(value);
  getValues = (): T => Object.keys(this.subjects).reduce((acc, key) => {
    acc[key as keyof T] = this.subjects[key as keyof T].getValue();
    return acc;
  }, {} as T);
}



import { useEffect, useState } from "react";
import { BehaviorSubject } from "rxjs";

export const useBehaviorSubject = <T>(subject: BehaviorSubject<T>) => {
  const [value, setValue] = useState<T>(subject.getValue());

  useEffect(() => {
    const subscription = subject.subscribe(setValue);
    return () => subscription.unsubscribe();
  }, [subject]);

  return [value, (val: T) => subject.next(val)] as const;
};



import { ReactElement } from "react";
import { BehaviorSubject } from "rxjs";
import { useBehaviorSubject } from "./useBehaviorSubject";

interface FormFieldProps<T> {
  field$: BehaviorSubject<T>;
  children: ReactElement<{ value: T; onChange: (val: any) => void }>;
}

const FormField = <T,>({ field$, children }: FormFieldProps<T>) => {
  const [value, setValue] = useBehaviorSubject(field$);

  return React.cloneElement(children, {
    value,
    onChange: (e: any) => setValue(e.target ? e.target.value : e),
  });
};

export default FormField;


const App = () => {
  const formStore = new FormStore<IArticleFields>({
    title: "",
    description: "",
    price: 0,
  });

  return (
    <div>
      <h3>Artikel Formulier</h3>

      <label>Titel:</label>
      <FormField field$={formStore.getField$("title")}>
        <input />
      </FormField>

      <label>Beschrijving:</label>
      <FormField field$={formStore.getField$("description")}>
        <textarea />
      </FormField>

      <label>Prijs:</label>
      <FormField field$={formStore.getField$("price")}>
        <input type="number" />
      </FormField>

      <button onClick={() => console.log("Submit:", formStore.getValues())}>Verzend</button>
    </div>
  );
};

export default App;


More helper code

import { App } from "antd";
import { IColumnMetaData } from "domain/base/IListView";
import { EventNames } from "domain/events/EventNames";
import DOMPurify from "dompurify";
import { DependencyList, Dispatch, ReactNode, RefObject, SetStateAction, useEffect, useMemo, useRef, useState } from "react";
import { useParams, useSearchParams } from "react-router-dom";
import { BehaviorSubject, debounceTime, fromEvent, map, Observable, Subject } from "rxjs";
import { appEmit, appSubscribe } from "../domain/DomainPorts";
import { ISubscribable } from "./Mutables/ISubscribable";
import { makeMut } from "./Mutables/Mutable";
import { IRect } from "./ParentSize";
import { getDate } from "./tshelper";
import { IEventSubscription } from "domain/events/IEventHandler";

export const When = (value: boolean, e: React.ReactNode) => value ? e : <></>;
export const OnlyWhen = (value: boolean, fn: () => React.JSX.Element) => value ? fn() : <></>;
export const OnlyWhenFalse = (value: boolean, fn: () => React.JSX.Element) => !value ? fn() : <></>;
export const WhenElse = (value: boolean, e: React.ReactNode, n: React.ReactNode) => value ? e : n;

export interface IDataSource<T> {
    data: T[];
    loading: boolean;
    setData: Dispatch<SetStateAction<T[]>>;
    updateData: () => any;
}

export interface IData<T> {
    data: T;
    loading: boolean;
    revision: number;
    loaded: boolean;
    setData: Dispatch<SetStateAction<T>>;
    updateData: () => any;
}

export interface ILazyLoadingData<T> {
    data?: T;
    loading: boolean;
    loaded: boolean;
    isChanged: boolean;
    load: () => any;
}
export function RenderHtml(htmlString: string): ReactNode {
    return <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(htmlString) }} />;
}

export function mapData<T>(columns: IColumnMetaData[], data: any[]) {
    const obj = {} as Record<string, any>;
    columns.forEach(({ name: fieldName }, i) => {
        obj[fieldName] = data[i];
    });
    obj.key = data[0];
    return obj as T;
}

export function useDatasource<T>(fetchFn: () => Promise<T[]>, deps: any[] = []): IDataSource<T> {
    const [trigger, setTrigger] = useState<boolean>(false);
    const [data, setData] = useState<T[]>([]);
    const [loading, setLoading] = useState(true);
    const [loaded, setLoaded] = useState(false);
    useEffect(() => {
        async function fetch() {
            setLoaded(false);
            setLoading(true);
            try {
                const newData = await fetchFn();
                setData(newData);
                setLoaded(true);
            } finally {
                setLoading(false);
            }
        }
        fetch();
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [...deps, trigger]);
    return {
        data,
        loading,
        loaded,
        setData,
        updateData: () => setTrigger(!trigger)
    } as IDataSource<T>;
}

export function useEffectAsync(func: () => unknown, deps?: DependencyList) {
    useEffect(() => {
        func();
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, deps);
}

// New useData version:
export interface IUseDataState<T> {
    data: T | undefined;
    trigger: number;
    revision: number;
    loading: boolean;
    loaded: boolean;
    initialLoading: boolean;
}

export function useData<T>(fetchFn: (currentData: IUseDataState<T> | undefined) => Promise<T>, deps: any = []): IData<T> {

    const [state, setState] = useObjectState<IUseDataState<T>>({
        revision: 0,
        trigger: 0,
        loaded: false,
        loading: true,
        initialLoading: true,
        data: undefined
    });

    useEffectAsync(async () => {
        if (!state.initialLoading) {
            setState({ loading: true, loaded: false });
        }
        let newState: Partial<IUseDataState<T>> = {};
        try {
            const newData = await fetchFn(state);
            newState = { data: newData, loaded: true, revision: state.revision + 1 };
        } catch (e) {
            console.error(e);
            newState = { data: undefined, revision: state.revision + 1 };
        } finally {
            setState({ ...newState, loading: false, initialLoading: false });
        }
    }, [...deps, state.trigger]);
    return {
        data: state.data,
        revision: state.revision,
        loading: state.loading,
        loaded: state.loaded,
        initialLoading: state.initialLoading,
        setData: (data: T) => setState({ data: data, revision: state.revision + 1 }),
        updateData: () => setState({ trigger: state.trigger + 1 })
    } as IData<T>;
}

export function useLazyLoadData<T>(fetchFn: () => Promise<T>, isDefaultChanged = false, deps: any = []): ILazyLoadingData<T> {
    const [data, setData] = useState<T>();
    const [loading, setLoading] = useState(false);
    const [loaded, setLoaded] = useState(false);
    const [isChanged, setIsChanged] = useState(isDefaultChanged);

    async function loadData() {
        setLoading(true);
        setLoaded(false);
        try {
            const newData = await fetchFn();
            setData(newData);
            setLoaded(true);
            setIsChanged(false);
        } catch (e) {
            console.error(e);
            setData(undefined);
        } finally {
            setLoading(false);
        }
    }

    useEffect(() => {
        setIsChanged(true);
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, deps);

    return {
        data,
        loading,
        loaded,
        isChanged,
        load: () => { loadData(); }
    };
}

export function useEvent(element: Node, eventName: string, listener: (e: any) => any, deps: any[] = []) {
    useEffect(() => {
        element.addEventListener(eventName, listener);
        return () => element.removeEventListener(eventName, listener);
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, deps);
}

export function useEventRef(refElement: React.RefObject<any>, eventName: string, listener: (e: any) => any, deps: any[] = []) {
    useEffect(() => {
        const element = refElement?.current;
        if (element) {
            element.addEventListener(eventName, listener);
            return () => element.removeEventListener(eventName, listener);
        }
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, deps);
}

export const useIsMobile = (): boolean => {
    const bp = 768;
    const [isMobile, setIsMobile] = useState(window.innerWidth < bp);
    useEffect(() => {
        const updateSize = (windowSize: number): void => {
            setIsMobile(windowSize < bp);
        };

        const resizeEvent = fromEvent(window, "resize");
        resizeEvent.pipe(
            map(x => (x.target as Window).innerWidth),
            debounceTime(500)
        ).subscribe(updateSize);
    }, []);
    return isMobile;
};

export function useSubject<T>(subject: Subject<T | undefined>, initialValue?: T): [T | undefined, (val: T | undefined) => any] {
    const [data, setData] = useState<T | undefined>(initialValue);
    useEffect(() => {
        if (!subject) { return; }
        const subscription = subject.subscribe((x: any) => {
            setData(x);
        });
        return () => subscription.unsubscribe();
    }, [subject]);
    function updateData(val: T | undefined) {
        subject.next(val);
    }
    return [data, updateData];
}
type ChangeFn<T> = (next: T | undefined) => any;
type SubscribeFn<T> = (nextFn: ChangeFn<T>) => IEventSubscription;

export function useSubscription<T>(subscribeFn: SubscribeFn<T>, deps: any[] = []): T | undefined {
    const [data, setData] = useState<T | undefined>(undefined);
    useEffect(() => {
        function onChange(next: T | undefined) {
            setData(next);
        }
        const subscription = subscribeFn(onChange);
        return () => subscription.unsubscribe();
    }, deps);// eslint-disable-line react-hooks/exhaustive-deps
    return data;
}

export function useAppSubscribe<T>(event: EventNames, deps: any[] = []): T | undefined {
    return useSubscription<T>((fn) => appSubscribe<T>(event, fn), deps);
}

export function useBehaviorSubject<T>(subject: BehaviorSubject<T>): T {
    return useObservable(subject, subject.value)!;
}

export function useObservable<T>(subject: Observable<T | undefined>, initialValue?: T): T | undefined {
    const [data, setData] = useState<T | undefined>(initialValue);
    useEffect(() => {
        if (!subject) { return; }
        const subscription = subject.subscribe((x: any) => {
            setData(x);
        });
        return () => subscription.unsubscribe();
    }, [subject]);
    return data;
}

export function useSubscribable<T>(subscribableObj: ISubscribable<T>, onChangeFn: (next: T | undefined) => any, deps: any[] = []): void {
    // eslint-disable-next-line react-hooks/exhaustive-deps
    const subscribeFn = useMemo(() => onChangeFn, deps);
    useEffect(() => {
        const subscription = subscribableObj.subscribe(subscribeFn);
        return () => subscription.unsubscribe();
    }, [subscribableObj, subscribeFn]);
}


export function useElementSize(ref: React.RefObject<HTMLElement>) {
    const [size, setSize] = useState<IRect>({ height: 0, width: 0 });
    useEffect(() => {
        const element = ref.current;
        if (!element) return;
        const onSizeChanged = () => {
            if (!element) return;
            const rect = element.getBoundingClientRect();
            setSize(rect);
        };
        const observer = new (window as any).ResizeObserver(onSizeChanged);
        observer.observe(element);
        return () => observer.unobserve(element);
    }, [ref]);
    return size;
}


export function useObjectState<T>(initialValue: T): [T, (c: Partial<T>) => any] {
    const [item, setItem] = useState<T>(initialValue);
    const handleChange = (changes: Partial<T>) => setItem(existing => ({ ...existing, ...changes }));
    return [item, handleChange];
}

export function useStateDebounce<T>(time: number, initialValue: T): [T, (v: T) => any] {
    const [value, setValue] = useState<T>(initialValue)
    const [values] = useState(() => new Subject<T>())
    useEffect(() => {
        const sub = values.pipe(debounceTime(time)).subscribe(setValue)
        return () => sub.unsubscribe()
    }, [time, values])
    return [value, (v: T) => values.next(v)];
}

export function decodeStateFromString<T>(stateStr: string) {
    return JSON.parse(atob(stateStr)) as T
}

export function encodeStateAsString<T>(state: T) {
    return btoa(JSON.stringify(state));
}

// De hook die de toestand beheert en de URL-parameters bijwerkt
export function useObjectStateInSearchParams<T>(nameOfUrlParam = "state"): [T, Dispatch<SetStateAction<T>>] {
    const [searchParams, setSearchParams] = useSearchParams();
    const state = searchParams.get(nameOfUrlParam);
    const initialProdPlanFilter = state ? decodeStateFromString<T>(state) : {} as T;
    const [value, setValue] = useState<T>(initialProdPlanFilter);
    // Effect om de URL bij te werken wanneer de toestand verandert
    useEffect(() => {
        const encodedState = encodeStateAsString(value);
        searchParams.set(nameOfUrlParam, encodedState);
        setSearchParams(searchParams, { replace: true });
    }, [value, searchParams, setSearchParams, nameOfUrlParam]);

    return [value, setValue];
}

export function useDebounce(callback: (...args: any[]) => any, delay: number) {
    const timeoutRef = useRef<any>(null);

    useEffect(() => {
        // Cleanup the previous timeout on re-render
        return () => {
            if (timeoutRef.current) {
                clearTimeout(timeoutRef.current);
            }
        };
    }, []);

    const debouncedCallback = (...args: any[]) => {
        if (timeoutRef.current) {
            clearTimeout(timeoutRef.current);
        }

        timeoutRef.current = setTimeout(() => {
            callback(...args);
        }, delay);
    };

    return debouncedCallback;
}

export function formatNumberWithEuro(amount: number | null | undefined) {
    if (amount == null || isNaN(amount)) {
        return '€ 0,00';
    }

    return new Intl.NumberFormat('nl-NL', {
        style: 'currency',
        currency: 'EUR',
        minimumFractionDigits: 2,
        maximumFractionDigits: 2,
    }).format(amount);
}

export function formatDateWithTime(dateString: any): string {
    const date = getDate(dateString);
    if (!date) return '-';
    if (isNaN(date.getTime())) return '-'; // invalid date
    const datePart = date.toLocaleDateString('nl-NL', { timeZone: 'UTC' });
    const hours = date.getUTCHours();
    const minutes = date.getUTCMinutes();

    if (hours === 0 && minutes === 0) {
        return datePart; // No time if midnight
    }

    const timePart = date.toLocaleTimeString('nl-NL', {
        hour: '2-digit',
        minute: '2-digit',
        timeZone: 'UTC'
    });

    return `${datePart} ${timePart}`;
}

// threshold, between 0 and 1, 1 is fully visible on screen.
export function useIsVisible(
    ref: RefObject<HTMLElement>,
    threshold: number = 0.01 // default: zodra iets in beeld is
) {
    const [isVisible, setIsVisible] = useState(false);

    const observer = useMemo(() => {
        return new IntersectionObserver(
            ([entry]) => {
                setIsVisible(entry.intersectionRatio >= threshold);
            },
            { threshold }
        );
    }, [threshold]);

    useEffect(() => {
        if (!ref.current) return;

        const current = ref.current;
        observer.observe(current);

        return () => {
            observer.unobserve(current);
            observer.disconnect();
        };
    }, [observer, ref]);

    return isVisible;
}

// threshold, between 0 and 1, 1 is fully visible on screen.
export function useVisibleThresholdVertical(ref: RefObject<HTMLElement>) {
    const thresholdObservableValue = useMemo(() => makeMut(0), []);

    const observer = useMemo(() => {
        return new IntersectionObserver(
            ([entry]) => {
                const intersectionRatioY = entry.intersectionRect.height / entry.boundingClientRect.height;
                //entry.intersectionRatio
                thresholdObservableValue.setValue(_ => intersectionRatioY);
            },
            {
                root: document.body,
                threshold: 1, // 0.00 → 1.00
            }
        );
    }, [thresholdObservableValue]);
    useEffect(() => {
        if (!ref.current) return;

        const current = ref.current;
        observer.observe(current);

        return () => {
            observer.unobserve(current);
            observer.disconnect();
        };
    }, [observer, ref]);

    return thresholdObservableValue;
}

export function parseToDate(dateLike: unknown): Date | null {
    if (!dateLike) return null;
    if (dateLike instanceof Date) return dateLike;
    const parsed = new Date(dateLike as string | number);
    return isNaN(parsed.getTime()) ? null : parsed;
}

export function getDutchMonthName(date: Date | null): string {
    if (!date) return "";
    return new Intl.DateTimeFormat('nl-NL', { month: 'long' }).format(date);
}

export function getFirstDayOfPreviousMonth(date: Date = new Date()): Date {
    return new Date(date.getFullYear(), date.getMonth() - 1, 1);
}

export function useLocalStorageWithEvents(key: string, initialValue: any, uniqueId: string) {
    const [storedValue, setStoredValue] = useState(() => {
        try {
            const item = window.localStorage.getItem(key);
            return item ? JSON.parse(item) : initialValue;
        } catch (error) {
            console.error(error);
            return initialValue;
        }
    });

    const isSettingValue = useRef(false);

    useEffect(() => {
        const handleLocalStorageChange = (data: any) => {
            // Controleer of het event van een andere bron komt
            if (data.key === key && data.uniqueId === uniqueId && !isSettingValue.current) {
                setStoredValue(data.newValue);
            }
        };

        const subscription = appSubscribe("localStorageChange", handleLocalStorageChange);

        return () => {
            subscription.unsubscribe();
        };
    }, [key, uniqueId]);

    const setValue = (value: any) => {
        try {
            const valueToStore = value instanceof Function ? value(storedValue) : value;
            isSettingValue.current = true;
            setStoredValue(valueToStore);
            window.localStorage.setItem(key, JSON.stringify(valueToStore));
            isSettingValue.current = false;

            // Emit the event with uniqueId
            appEmit(EventNames.localStorageChange, { key, newValue: valueToStore, uniqueId });
        } catch (error) {
            console.error(error);
        }
    };

    return [storedValue, setValue];
}

export interface IDestroyable { destroy: () => any }
export interface IModalInstance { showModel: (fn: (ref: IDestroyable) => any) => void }

export function useAppModel(): IModalInstance {
    const app = App.useApp();
    function showModel(fn: (ref: IDestroyable) => any) {
        let model: IDestroyable | undefined = undefined;
        const destroyable: IDestroyable = {
            destroy: () => model?.destroy()
        }
        model = app.modal.success(fn(destroyable));
    }
    return {
        showModel: showModel
    };
}



/**
 * Geeft de zichtbare verticale ratio van het element binnen de container.
 * @returns Een waarde tussen 0 en 1 (0 = niet zichtbaar, 1 = volledig zichtbaar)
 */
export function visibleThresholdVertical(el: HTMLElement, containerRect: DOMRect): number {
    const elRect = el.getBoundingClientRect();

    const visibleTop = Math.max(elRect.top, containerRect.top);
    const visibleBottom = Math.min(elRect.bottom, containerRect.bottom);

    const visibleHeight = Math.max(0, visibleBottom - visibleTop);
    const elHeight = elRect.height;

    if (elHeight === 0) return 0; // Voorkom delen door 0

    const visibleRatio = visibleHeight / elHeight;
    return Math.min(1, Math.max(0, visibleRatio)); // Clamp naar [0, 1]
}

export interface IVisibleChecker {
    visibleThresholdVertical(e: HTMLElement): number;
}

export function useVisibleCheckerForRef(ref: RefObject<HTMLElement>) {
    const visibleChecker = useMemo(() => makeMut<IVisibleChecker>({ visibleThresholdVertical: (_) => 0 }), []);
    useEffect(() => {
        if (!ref.current) return;
        const e = ref.current;

        function onScroll() {
            const containerRect = e.getBoundingClientRect();
            const isVisible = (el: HTMLElement) => visibleThresholdVertical(el, containerRect);
            visibleChecker.setValue(_ => ({ visibleThresholdVertical: isVisible }));
        }

        e.addEventListener("scroll", onScroll, { passive: true });
        const observer = new (window as any).ResizeObserver(onScroll);
        observer.observe(e);

        onScroll();
        return () => {
            e.removeEventListener("scroll", onScroll);
            observer.unobserve(e);
        };
    }, [ref, visibleChecker]);
    return visibleChecker;
}

export function useLatest<T>(value: T) {
    const ref = useRef(value);
    useEffect(() => {
        ref.current = value;
    }, [value]);
    return ref;
}

/**
 * Hook to extract the 'id' parameter from the URL.
 * Returns undefined if the id is 'new', otherwise returns the id as string.
 * This allows for creating new items (POST) vs editing existing items (GET).
 */
export function useIdParam(): string | undefined {
    const { id } = useParams<{ id: string }>();
    return id === 'new' ? undefined : id;
}