Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Hookify react-countup #515

Merged
merged 27 commits into from
Aug 14, 2021
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
770c2ec
Typescript-ify.
johnrom Jul 22, 2021
c781bae
Converted `CountUp` to a Functional Component. Now just need to switc…
johnrom Jul 22, 2021
d145fd4
Fix this possibly backwards-incompatible type.
johnrom Jul 22, 2021
6974102
Fix instance config to only support hook.
johnrom Jul 22, 2021
994dca3
Fix some effect issues.
johnrom Jul 22, 2021
04bde8a
The hook version and the component version used two different methods…
johnrom Jul 22, 2021
9818c49
Remove unnecessary prop.
johnrom Jul 22, 2021
ce7b00a
Test fixes and some TypeScript fixes.
johnrom Jul 23, 2021
ab1247c
Add TS to build process.
johnrom Jul 23, 2021
b3a9f47
Fix build script.
johnrom Jul 23, 2021
7861a31
Merge branch 'johnrom/typescript' into johnrom/hookify
johnrom Jul 23, 2021
168aefd
Check delay for undefined-ness as well.
johnrom Jul 23, 2021
a350e77
Remove proptypes.
johnrom Jul 26, 2021
840fe99
Timeout should use the return type of setTimeout, as it could be Node…
johnrom Jul 26, 2021
9e9a18f
Check for existing instance before checking for instance.target.
johnrom Jul 26, 2021
7515613
Fixed createInstance Element warning.
johnrom Jul 26, 2021
6b1f465
Fixed createInstance element warning (2)
johnrom Jul 26, 2021
5b9412a
Clean up package.json.
johnrom Jul 26, 2021
d5a90b8
Merge branch 'johnrom/typescript' into johnrom/hookify
johnrom Jul 26, 2021
88489b8
Remove @babel/plugin-class-properties as we no longer use a class.
johnrom Jul 26, 2021
cdad744
Remove prop-types from rollup config.
johnrom Jul 26, 2021
dc53f87
Merge branch 'johnrom/typescript' into johnrom/hookify
johnrom Jul 26, 2021
dc29f9d
Merge remote-tracking branch 'upstream/master' into johnrom/hookify
johnrom Jul 26, 2021
4e291e1
Merge remote-tracking branch 'upstream/master' into johnrom/hookify
johnrom Aug 12, 2021
e5aaf82
Remove plugin-proposal-class-properties as we no longer use a class.
johnrom Aug 12, 2021
e02fa87
Use destructuring in useCountUp.
johnrom Aug 13, 2021
0b8e174
Missed a line from my previous commit.
johnrom Aug 13, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
11 changes: 1 addition & 10 deletions src/common.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,7 @@
import { CountUp } from 'countup.js';
import { CountUpProps } from './CountUp';
import { useCountUpProps } from './useCountUp';

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 | HTMLInputElement, props: useCountUpProps) => {
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;
Loading