Skip to content

Commit 3d4e6bd

Browse files
authored
Merge pull request #8 from AlexStack/nextjs14
add timeout to getApiResponse
2 parents 660c1f6 + 0a525f7 commit 3d4e6bd

File tree

4 files changed

+109
-11
lines changed

4 files changed

+109
-11
lines changed

README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ If you prefer Tailwind css, check this: [Tailwind-CSS-Version](https://github.co
1414

1515
## Demo
1616

17-
[<img src="./public/images/cover.png">](https://mui-nextjs-ts.vercel.app)
17+
[<img src="https://alexstack.github.io/reactStarter/asset/NextJs14-mui5.gif">](https://mui-nextjs-ts.vercel.app)
1818

1919
🚘🚘🚘 [**Click here to see an online demo**](https://mui-nextjs-ts.vercel.app) 🚘🚘🚘
2020

src/app/page.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@ const loadDataFromApi = async (slug?: string) => {
1414
getApiResponse<NpmData>({
1515
apiEndpoint: 'https://registry.npmjs.org/react/latest',
1616
revalidate: 60 * 60 * 24, // 24 hours cache
17+
timeout: 5000, // 5 seconds
1718
}),
1819
getApiResponse<NpmData>({
1920
apiEndpoint: 'https://registry.npmjs.org/next/latest',
2021
revalidate: 0, // no cache
22+
timeout: 5000, // 5 seconds
2123
}),
2224
]);
2325

src/components/shared/DisplayRandomPicture.tsx

+76-9
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
/* eslint-disable @next/next/no-img-element */
22

33
'use client';
4-
import { Send } from '@mui/icons-material';
4+
import styled from '@emotion/styled';
5+
import { Autorenew, Send } from '@mui/icons-material';
6+
import { css, keyframes } from '@mui/material';
57
import Avatar from '@mui/material/Avatar';
68
import Button from '@mui/material/Button';
9+
import { purple } from '@mui/material/colors';
710
import Stack from '@mui/material/Stack';
811
import React, { useEffect, useState } from 'react';
912

@@ -12,42 +15,60 @@ import { useClientContext } from '@/hooks/useClientContext';
1215

1316
import SubmitButton from '@/components/shared/SubmitButton';
1417

18+
import { getApiResponse } from '@/utils/shared/get-api-response';
19+
1520
const DisplayRandomPicture = () => {
1621
const [imageUrl, setImageUrl] = useState('');
17-
const [loading, setLoading] = useState(true);
22+
const [loading, setLoading] = useState(false);
1823
const [error, setError] = useState('');
1924
const { fetchCount, updateClientCtx } = useClientContext();
2025
const { setAlertBarProps, renderAlertBar } = useAlertBar();
2126
const renderCountRef = React.useRef(0);
2227

2328
const fetchRandomPicture = async () => {
29+
if (loading) {
30+
setAlertBarProps({
31+
message: 'Please wait for the current fetch to complete',
32+
severity: 'warning',
33+
});
34+
return;
35+
}
2436
setLoading(true);
2537
setError('');
2638

2739
try {
28-
const response = await fetch('https://picsum.photos/300/150');
29-
if (!response.ok) {
30-
throw new Error('Error fetching the image');
40+
const response = await getApiResponse<Response & { url: string }>({
41+
apiEndpoint: 'https://picsum.photos/300/160',
42+
timeout: 5001,
43+
});
44+
45+
if (!response?.url) {
46+
throw new Error('Error fetching the image, no response url');
3147
}
48+
3249
setImageUrl(response.url);
3350
updateClientCtx({ fetchCount: fetchCount + 1 });
3451
setAlertBarProps({
3552
message: 'A random picture fetched successfully',
3653
severity: 'info',
3754
});
3855
} catch (error) {
39-
setError('Error fetching the image');
56+
const errorMsg =
57+
error instanceof Error ? error.message : 'Error fetching the image';
58+
59+
setError(errorMsg);
4060
setAlertBarProps({
41-
message: 'Error fetching the image',
61+
message: errorMsg,
4262
severity: 'error',
4363
});
64+
setLoading(false);
4465
} finally {
4566
setLoading(false);
4667
}
4768
};
4869

4970
useEffect(() => {
50-
if (renderCountRef.current === 0) {
71+
if (renderCountRef.current === 0 && !loading) {
5172
fetchRandomPicture();
5273
}
5374
renderCountRef.current += 1;
@@ -59,6 +80,7 @@ const DisplayRandomPicture = () => {
5980
justifyContent='center'
6081
alignItems='center'
6182
spacing={2}
83+
sx={{ position: 'relative', width: '300px', margin: '0 auto' }}
6284
>
6385
{error && <p>{error}</p>}
6486
{imageUrl && (
@@ -71,7 +93,7 @@ const DisplayRandomPicture = () => {
7193
)}
7294
<div>
7395
{loading && <span>Loading...</span>} Component Render Count:{' '}
74-
{renderCountRef.current}
96+
{renderCountRef.current + 1}
7597
</div>
7698

7799
<SubmitButton
@@ -88,9 +110,54 @@ const DisplayRandomPicture = () => {
88110
Get Another Picture
89111
</Button>
90112
</SubmitButton>
113+
{imageUrl && (
114+
<StyledRefreshButton onClick={fetchRandomPicture} loading={loading}>
115+
<Avatar sx={{ width: 24, height: 24 }}>
116+
<Autorenew />
117+
</Avatar>
118+
</StyledRefreshButton>
119+
)}
91120
{renderAlertBar()}
92121
</Stack>
93122
);
94123
};
95124

125+
const spin = keyframes`
126+
from {
127+
transform: rotate(0deg);
128+
}
129+
to {
130+
transform: rotate(360deg);
131+
}
132+
`;
133+
const StyledRefreshButton = styled.div<{ loading?: boolean }>`
134+
position: absolute;
135+
right: 0;
136+
top: 0;
137+
margin: 0.5rem !important;
138+
pointer-events: ${({ loading }) => (loading ? 'none' : 'auto')};
139+
opacity: ${({ loading }) => (loading ? '0.6' : '1')};
140+
cursor: ${({ loading }) => (loading ? 'not-allowed' : 'pointer')};
141+
svg {
142+
width: 20px;
143+
height: 20px;
144+
animation: ${({ loading }) =>
145+
loading
146+
? css`
147+
${spin} 2s linear infinite
148+
`
149+
: 'none'};
150+
}
151+
:hover {
152+
svg {
153+
path {
154+
fill: ${purple[500]};
155+
}
156+
}
157+
.MuiAvatar-circular {
158+
background-color: ${purple[50]};
159+
}
160+
}
161+
`;
162+
96163
export default DisplayRandomPicture;

src/utils/shared/get-api-response.ts

+30-1
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,48 @@
11
import { IS_PROD } from '@/constants';
22
import { consoleLog } from '@/utils/shared/console-log';
33

4+
/**
5+
* Makes an API request and returns the response data.
6+
*
7+
* @param apiEndpoint - The API endpoint URL.
8+
* @param requestData - The request data to be sent in the request body.
9+
* @param method - The HTTP method for the request (default: 'GET').
10+
* @param revalidate - The time in seconds to cache the data (default: 3600 seconds in production, 120 seconds otherwise).
11+
* @param headers - The headers to be included in the request.
12+
* @param timeout - The timeout in milliseconds for the request (default: 100000 = 100 seconds).
13+
* @returns The response data from the API.
14+
* @throws An error if the API request fails or times out.
15+
*/
416
export const getApiResponse = async <T>({
517
apiEndpoint,
618
requestData,
719
method = 'GET',
820
revalidate = IS_PROD ? 3600 : 120, // cache data in seconds
921
headers,
22+
timeout = 100000, // 100 seconds
1023
}: {
1124
apiEndpoint: string;
1225
requestData?: BodyInit;
1326
method?: 'POST' | 'GET' | 'PUT' | 'DELETE';
1427
revalidate?: number;
1528
headers?: HeadersInit;
29+
timeout?: number;
1630
}) => {
1731
try {
1832
const startTime = Date.now();
33+
const controller = new AbortController();
34+
const signal = controller.signal;
35+
36+
const timeoutId = setTimeout(() => controller.abort(), timeout);
37+
1938
const response = await fetch(apiEndpoint, {
2039
method,
2140
body: requestData,
2241
headers,
2342
next: {
2443
revalidate,
2544
},
45+
signal,
2646
});
2747
if (!response.ok) {
2848
consoleLog('🚀 Debug getApiResponse requestData:', requestData);
@@ -38,9 +58,18 @@ export const getApiResponse = async <T>({
3858
duration > 2000 ? '💔' : '-'
3959
} ${apiEndpoint}`
4060
);
41-
61+
clearTimeout(timeoutId);
62+
// if is not valid JSON, return response
63+
if (!response.headers.get('content-type')?.includes('application/json')) {
64+
return response as T;
65+
}
4266
return (await response.json()) as T;
4367
} catch (error) {
68+
if (error instanceof Error && error.name === 'AbortError') {
69+
throw new Error(
70+
'Fetch request timed out: ' + (timeout / 1000).toFixed(1) + ' s'
71+
);
72+
}
4473
consoleLog('getApiResponse error:', error);
4574
}
4675

0 commit comments

Comments
 (0)