From 8fbbaa65b96896b55e543d6581fc52535ccbf04a Mon Sep 17 00:00:00 2001 From: John Rom Date: Sat, 14 Aug 2021 08:30:40 -0400 Subject: [PATCH] Hookify react-countup (#515) * Typescript-ify. * Converted `CountUp` to a Functional Component. Now just need to switch it to use `useCountUp`. * Fix this possibly backwards-incompatible type. * Fix instance config to only support hook. * Fix some effect issues. * The hook version and the component version used two different methods -- the component initialized immediately and set "0" as the value, and the hook delayed initialization and returned "" as the value. This breaks backwards compatibility by setting both to "0", though it may need to be re-thought. * Remove unnecessary prop. * Test fixes and some TypeScript fixes. * Add TS to build process. * Fix build script. * Check delay for undefined-ness as well. * Remove proptypes. * Timeout should use the return type of setTimeout, as it could be NodeJS.Timeout in node, or number in the browser. * Check for existing instance before checking for instance.target. * Fixed createInstance Element warning. * Fixed createInstance element warning (2) * Clean up package.json. * Remove @babel/plugin-class-properties as we no longer use a class. * Remove prop-types from rollup config. * Remove plugin-proposal-class-properties as we no longer use a class. * Use destructuring in useCountUp. * Missed a line from my previous commit. --- babel.config.json | 2 - package.json | 1 - src/CountUp.tsx | 224 +++++++---------------- src/__tests__/CountUp.test.js | 8 +- src/common.ts | 16 +- src/helpers/useEventCallback.ts | 22 +++ src/helpers/useIsomorphicLayoutEffect.ts | 14 ++ src/types.ts | 7 +- src/useCountUp.ts | 137 +++++++++----- yarn.lock | 2 +- 10 files changed, 216 insertions(+), 217 deletions(-) create mode 100644 src/helpers/useEventCallback.ts create mode 100644 src/helpers/useIsomorphicLayoutEffect.ts diff --git a/babel.config.json b/babel.config.json index fe21b8f2..bf51b19b 100644 --- a/babel.config.json +++ b/babel.config.json @@ -4,10 +4,8 @@ "@babel/preset-react", "@babel/preset-typescript" ], - "plugins": ["@babel/plugin-proposal-class-properties"], "env": { "test": { - "plugins": ["@babel/plugin-proposal-class-properties"], "presets": [ ["@babel/preset-env", { "targets": { "node": "current" } }] ] diff --git a/package.json b/package.json index b9c37550..e2649668 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,6 @@ }, "devDependencies": { "@babel/core": "7.14.8", - "@babel/plugin-proposal-class-properties": "7.14.5", "@babel/preset-env": "7.14.8", "@babel/preset-react": "7.14.5", "@babel/preset-typescript": "^7.14.5", diff --git a/src/CountUp.tsx b/src/CountUp.tsx index 71f58a84..751d20b2 100644 --- a/src/CountUp.tsx +++ b/src/CountUp.tsx @@ -1,179 +1,97 @@ -import React, { Component, CSSProperties } from 'react'; -import { createCountUpInstance, DEFAULTS } from './common'; -import { CountUp as CountUpJs } from 'countup.js'; +import React, { CSSProperties, useEffect } from 'react'; import { CallbackProps, CommonProps, RenderCounterProps } from './types'; +import { useEventCallback } from './helpers/useEventCallback'; +import useCountUp from './useCountUp'; export interface CountUpProps extends CommonProps, CallbackProps { className?: string; redraw?: boolean; - preserveValue?: boolean; children?: (props: RenderCounterProps) => React.ReactNode; style?: CSSProperties; + preserveValue?: boolean; } -class CountUp extends Component { - private instance: CountUpJs | undefined; - private timeoutId: ReturnType | undefined; - - static defaultProps = { - ...DEFAULTS, - redraw: false, - style: undefined, - preserveValue: false, - }; - - componentDidMount() { - const { children, delay } = this.props; - this.instance = this.createInstance(); - - // Don't invoke start if component is used as a render prop - if (typeof children === 'function' && delay !== 0) return; - - // Otherwise just start immediately - this.start(); - } - - checkProps = (updatedProps: CountUpProps) => { - const { - start, - suffix, - prefix, - redraw, - duration, - separator, - decimals, - decimal, - className, - formattingFn - } = this.props; - - const hasPropsChanged = - duration !== updatedProps.duration || - start !== updatedProps.start || - suffix !== updatedProps.suffix || - prefix !== updatedProps.prefix || - separator !== updatedProps.separator || - decimals !== updatedProps.decimals || - decimal !== updatedProps.decimal || - className !== updatedProps.className || - formattingFn !== updatedProps.formattingFn; - - return hasPropsChanged || redraw; - }; - - shouldComponentUpdate(nextProps: CountUpProps) { - const { end } = this.props; - return this.checkProps(nextProps) || end !== nextProps.end; - } - - componentDidUpdate(prevProps: CountUpProps) { - // If duration, suffix, prefix, separator or start has changed - // there's no way to update the values. - // So we need to re-create the CountUp instance in order to - // restart it. - const { end, preserveValue } = this.props; - - if (this.checkProps(prevProps)) { - this.instance?.reset(); - this.instance = this.createInstance(); - this.start(); +const CountUp = (props: CountUpProps) => { + const { className, redraw, children, style, ...useCountUpProps } = props; + const containerRef = React.useRef(null); + const isInitializedRef = React.useRef(false); + + const countUp = useCountUp({ + ...useCountUpProps, + ref: containerRef, + startOnMount: typeof children !== 'function' || props.delay === 0, + // component manually restarts + enableReinitialize: false, + }); + + const restart = useEventCallback(() => { + countUp.start(); + }); + + const update = useEventCallback((end: string | number) => { + if (!props.preserveValue) { + countUp.reset(); } + countUp.update(end); + }); - // Only end value has changed, so reset and and re-animate with the updated - // end value. - if (end !== prevProps.end) { - if (!preserveValue) { - this.instance?.reset(); - } - this.instance?.update(end); - } - } - - componentWillUnmount() { - if (this.timeoutId) { - clearTimeout(this.timeoutId); - } - - this.instance?.reset(); - } - - createInstance = () => { - if (typeof this.props.children === 'function') { + useEffect(() => { + if (typeof props.children === 'function') { // Warn when user didn't use containerRef at all - if (!(this.containerRef.current instanceof Element)) { + if (!(containerRef.current instanceof Element)) { console.error(`Couldn't find attached element to hook the CountUp instance into! Try to attach "containerRef" from the render prop to a an Element, eg. .`); return; } } - return createCountUpInstance(this.containerRef.current, this.props); - }; - - pauseResume = () => { - const { reset, restart: start, update } = this; - const { onPauseResume } = this.props; - - this.instance?.pauseResume(); - - onPauseResume?.({ reset, start, update }); - }; - - reset = () => { - const { pauseResume, restart: start, update } = this; - const { onReset } = this.props; - - this.instance?.reset(); - - onReset?.({ pauseResume, start, update }); - }; - restart = () => { - this.reset(); - this.start(); - }; + // unlike the hook, the CountUp component initializes on mount + countUp.getCountUp(); + }, []); - start = () => { - const { pauseResume, reset, restart: start, update } = this; - const { delay, onEnd, onStart } = this.props; - const run = () => - this.instance?.start(() => onEnd?.({ pauseResume, reset, start, update })); - - // Delay start if delay prop is properly set - if (delay && delay > 0) { - this.timeoutId = setTimeout(run, delay * 1000); - } else { - run(); + useEffect(() => { + if (isInitializedRef.current) { + update(props.end); } + }, [props.end]); - onStart?.({ pauseResume, reset, update }); - }; - - update = (newEnd: string | number) => { - const { pauseResume, reset, restart: start } = this; - const { onUpdate } = this.props; - - this.instance?.update(newEnd); - - onUpdate?.({ pauseResume, reset, start }); - }; - - containerRef = React.createRef(); - - render() { - const { children, className, style } = this.props; - const { containerRef, pauseResume, reset, restart, update } = this; - - if (typeof children === 'function') { - return children({ - countUpRef: containerRef, - pauseResume, - reset, - start: restart, - update, - }); + // if props.redraw, call this effect on every props change + useEffect(() => { + if (props.redraw && isInitializedRef.current) { + restart(); } + }, [props.redraw && props]); - return ; + // if not props.redraw, call this effect only when certain props are changed + useEffect(() => { + if (!props.redraw && isInitializedRef.current) { + restart(); + } + }, [ + props.redraw, + props.start, + props.suffix, + props.prefix, + props.duration, + props.separator, + props.decimals, + props.decimal, + props.className, + props.formattingFn + ]); + + useEffect(() => { + isInitializedRef.current = true; + }, []); + + if (typeof children === 'function') { + // TypeScript forces functional components to return JSX.Element | null. + return children({ + countUpRef: containerRef, + ...countUp, + }) as JSX.Element | null; } + + return ; } export default CountUp; diff --git a/src/__tests__/CountUp.test.js b/src/__tests__/CountUp.test.js index e92307dd..c7933283 100644 --- a/src/__tests__/CountUp.test.js +++ b/src/__tests__/CountUp.test.js @@ -158,11 +158,11 @@ describe('CountUp component', () => { }); it('re-renders with redraw={true} correctly', () => { - const componentRender = jest.spyOn(CountUp.prototype, 'render'); - const { rerender } = render(); + const onStart = jest.fn(); + const { rerender } = render(); - rerender(); - expect(componentRender).toHaveBeenCalledTimes(2); + rerender(); + expect(onStart).toHaveBeenCalledTimes(2); }); it('does not reset if preserveValue is true', (done) => { diff --git a/src/common.ts b/src/common.ts index b43366bd..f8a34a4c 100644 --- a/src/common.ts +++ b/src/common.ts @@ -1,16 +1,10 @@ import { CountUp } from 'countup.js'; -import { CountUpProps } from './CountUp'; -import { useCountUpProps } from './useCountUp'; +import { CountUpInstanceProps } from './types'; -export const DEFAULTS = { - decimal: '.', - delay: null, - prefix: '', - suffix: '', - start: 0 -} - -export const createCountUpInstance = (el: string | HTMLElement | HTMLInputElement, props: useCountUpProps | CountUpProps) => { +export const createCountUpInstance = ( + el: string | HTMLElement, + props: CountUpInstanceProps, +) => { const { decimal, decimals, diff --git a/src/helpers/useEventCallback.ts b/src/helpers/useEventCallback.ts new file mode 100644 index 00000000..2f4616df --- /dev/null +++ b/src/helpers/useEventCallback.ts @@ -0,0 +1,22 @@ +import { useCallback, useRef } from "react"; +import { useIsomorphicLayoutEffect } from "./useIsomorphicLayoutEffect"; + +/** + * Create a stable reference to a callback which is updated after each render is committed. + * Typed version borrowed from Formik v2.2.1. Licensed MIT. + * + * https://github.com/formium/formik/blob/9316a864478f8fcd4fa99a0735b1d37afdf507dc/LICENSE + */ +export function useEventCallback any>(fn: T): T { + const ref: any = useRef(fn); + + // we copy a ref to the callback scoped to the current state/props on each render + useIsomorphicLayoutEffect(() => { + ref.current = fn; + }); + + return useCallback( + (...args: any[]) => ref.current.apply(void 0, args), + [] + ) as T; +} diff --git a/src/helpers/useIsomorphicLayoutEffect.ts b/src/helpers/useIsomorphicLayoutEffect.ts new file mode 100644 index 00000000..2feea324 --- /dev/null +++ b/src/helpers/useIsomorphicLayoutEffect.ts @@ -0,0 +1,14 @@ +import { useEffect, useLayoutEffect } from 'react'; + +/** + * Silence SSR Warnings. + * Borrowed from Formik v2.1.1, Licensed MIT. + * + * https://github.com/formium/formik/blob/9316a864478f8fcd4fa99a0735b1d37afdf507dc/LICENSE + */ +export const useIsomorphicLayoutEffect = + typeof window !== 'undefined' && + typeof window.document !== 'undefined' && + typeof window.document.createElement !== 'undefined' + ? useLayoutEffect + : useEffect; diff --git a/src/types.ts b/src/types.ts index ef48ca22..402916d0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -41,10 +41,9 @@ export type OnUpdateCallback = (args: OnUpdateArgs) => void; type EasingFn = (t: number, b: number, c: number, d: number) => number; -export interface CommonProps { +export interface CountUpInstanceProps { decimal?: string; decimals?: number; - delay?: number | null; duration?: number; easingFn?: EasingFn; end: number; @@ -57,6 +56,10 @@ export interface CommonProps { numerals?: string[]; } +export interface CommonProps extends CountUpInstanceProps { + delay?: number | null; +} + export interface CallbackProps { onEnd?: OnEndCallback; onStart?: OnStartCallback; diff --git a/src/useCountUp.ts b/src/useCountUp.ts index 05fd4b14..e08bfff2 100644 --- a/src/useCountUp.ts +++ b/src/useCountUp.ts @@ -1,28 +1,51 @@ import { CallbackProps, CommonProps, UpdateFn } from './types'; -import React, { useEffect, useRef } from 'react'; -import { createCountUpInstance, DEFAULTS } from './common'; +import { useMemo, useRef, useEffect } from 'react'; +import { createCountUpInstance } from './common'; +import { useEventCallback } from './helpers/useEventCallback'; import { CountUp as CountUpJs } from 'countup.js'; export interface useCountUpProps extends CommonProps, CallbackProps { startOnMount?: boolean; - ref: string | React.RefObject; + ref: string | React.MutableRefObject; + enableReinitialize?: boolean; } -const defaults: Partial = { - ...DEFAULTS, +const DEFAULTS = { + decimal: '.', + delay: null, + prefix: '', + suffix: '', + start: 0, startOnMount: true, + enableReinitialize: true, }; const useCountUp = (props: useCountUpProps) => { - const parsedProps = { ...defaults, ...props }; - const { ref } = parsedProps; + const { + ref, + startOnMount, + enableReinitialize, + delay, + onEnd, + onStart, + onPauseResume, + onReset, + onUpdate, + ...instanceProps + } = useMemo(() => ({ ...DEFAULTS, ...props }), [props]); + const countUpRef = useRef(); - const timerRef = useRef(); + const timerRef = useRef>(); + const isInitializedRef = useRef(false); - const createInstance = () => - createCountUpInstance(typeof ref === 'string' ? ref : ref.current, parsedProps); + const createInstance = useEventCallback(() => { + return createCountUpInstance( + typeof ref === 'string' ? ref : ref.current, + instanceProps + ) + }); - const getCountUp = (recreate?: boolean) => { + const getCountUp = useEventCallback((recreate?: boolean) => { const countUp = countUpRef.current; if (countUp && !recreate) { return countUp; @@ -30,53 +53,81 @@ const useCountUp = (props: useCountUpProps) => { const newCountUp = createInstance(); countUpRef.current = newCountUp; return newCountUp; - }; + }); - const reset = () => { - const { onReset } = parsedProps; - getCountUp().reset(); - onReset?.({ pauseResume, start: restart, update }); - }; + const start = useEventCallback(() => { + const run = () => + getCountUp(true).start(() => { + onEnd?.({ pauseResume, reset, start: restart, update }); + }); + + if (delay && delay > 0) { + timerRef.current = setTimeout(run, delay * 1000); + } else { + run(); + } - const restart = () => { - const { onStart, onEnd } = parsedProps; - getCountUp().reset(); onStart?.({ pauseResume, reset, update }); - getCountUp().start(() => { - onEnd?.({ pauseResume, reset, start: restart, update }); - }); - }; + }); - const pauseResume = () => { - const { onPauseResume } = parsedProps; + const pauseResume = useEventCallback(() => { getCountUp().pauseResume(); + onPauseResume?.({ reset, start: restart, update }); - }; + }); - const update: UpdateFn = (newEnd) => { - const { onUpdate } = parsedProps; + const reset = useEventCallback(() => { + timerRef.current && clearTimeout(timerRef.current); + + getCountUp().reset(); + + onReset?.({ pauseResume, start: restart, update }); + }); + + const update: UpdateFn = useEventCallback((newEnd) => { getCountUp().update(newEnd); + onUpdate?.({ pauseResume, reset, start: restart }); - }; + }); - useEffect(() => { - const { delay, onStart, onEnd, startOnMount } = parsedProps; + const restart = useEventCallback(() => { + reset(); + start(); + }); + + const maybeInitialize = useEventCallback(() => { if (startOnMount) { - timerRef.current = setTimeout(() => { - onStart?.({ pauseResume, reset, update }); - getCountUp(true).start(() => { - timerRef.current && clearTimeout(timerRef.current); - onEnd?.({ pauseResume, reset, start: restart, update }); - }); - }, delay ? delay * 1000 : 0); + start(); } + }); + + const maybeReinitialize = () => { + if (startOnMount) { + reset(); + start(); + } + } + + useEffect(() => { + if (!isInitializedRef.current) { + isInitializedRef.current = true; + + maybeInitialize(); + } else if (enableReinitialize) { + maybeReinitialize(); + } + }, [ + maybeInitialize, + props, + ]); + + useEffect(() => { return () => { - timerRef.current && clearTimeout(timerRef.current); reset(); - }; - }, [parsedProps]); + } + }, []); - return { start: restart, pauseResume, reset, update }; + return { start: restart, pauseResume, reset, update, getCountUp }; }; export default useCountUp; diff --git a/yarn.lock b/yarn.lock index 8ea03df2..0106c0e3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -555,7 +555,7 @@ "@babel/helper-remap-async-to-generator" "^7.14.5" "@babel/plugin-syntax-async-generators" "^7.8.4" -"@babel/plugin-proposal-class-properties@7.14.5", "@babel/plugin-proposal-class-properties@^7.14.5": +"@babel/plugin-proposal-class-properties@^7.14.5": version "7.14.5" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.14.5.tgz#40d1ee140c5b1e31a350f4f5eed945096559b42e" integrity sha512-q/PLpv5Ko4dVc1LYMpCY7RVAAO4uk55qPwrIuJ5QJ8c6cVuAmhu7I/49JOppXL6gXf7ZHzpRVEUZdYoPLM04Gg==