Skip to content

Commit

Permalink
Hookify react-countup (#515)
Browse files Browse the repository at this point in the history
* 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.
  • Loading branch information
johnrom committed Aug 14, 2021
1 parent b5df358 commit 8fbbaa6
Show file tree
Hide file tree
Showing 10 changed files with 216 additions and 217 deletions.
2 changes: 0 additions & 2 deletions babel.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" } }]
]
Expand Down
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
224 changes: 71 additions & 153 deletions src/CountUp.tsx
Original file line number Diff line number Diff line change
@@ -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<CountUpProps> {
private instance: CountUpJs | undefined;
private timeoutId: ReturnType<typeof setTimeout> | 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<any>(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. <span ref={containerRef} />.`);
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<any>();

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 <span className={className} ref={containerRef} style={style} />;
// 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 <span className={className} ref={containerRef} style={style} />;
}

export default CountUp;
8 changes: 4 additions & 4 deletions src/__tests__/CountUp.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -158,11 +158,11 @@ describe('CountUp component', () => {
});

it('re-renders with redraw={true} correctly', () => {
const componentRender = jest.spyOn(CountUp.prototype, 'render');
const { rerender } = render(<CountUp end={10} redraw/>);
const onStart = jest.fn();
const { rerender } = render(<CountUp end={10} onStart={onStart} redraw />);

rerender(<CountUp end={10}/>);
expect(componentRender).toHaveBeenCalledTimes(2);
rerender(<CountUp end={10} onStart={onStart} redraw />);
expect(onStart).toHaveBeenCalledTimes(2);
});

it('does not reset if preserveValue is true', (done) => {
Expand Down
16 changes: 5 additions & 11 deletions src/common.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
22 changes: 22 additions & 0 deletions src/helpers/useEventCallback.ts
Original file line number Diff line number Diff line change
@@ -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<T extends (...args: any[]) => 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;
}
14 changes: 14 additions & 0 deletions src/helpers/useIsomorphicLayoutEffect.ts
Original file line number Diff line number Diff line change
@@ -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;
7 changes: 5 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -57,6 +56,10 @@ export interface CommonProps {
numerals?: string[];
}

export interface CommonProps extends CountUpInstanceProps {
delay?: number | null;
}

export interface CallbackProps {
onEnd?: OnEndCallback;
onStart?: OnStartCallback;
Expand Down
Loading

0 comments on commit 8fbbaa6

Please sign in to comment.