Fix React hooks ESLint errors without disabling rules

Refactored components to avoid synchronous setState calls in effects
by using proper React patterns:

- TableOfContents: Use requestAnimationFrame to defer setState calls
  and useLayoutEffect for DOM measurements
- CustomTheme: Use lazy state initialization for isClient and defer
  theme loading with requestAnimationFrame
- PlaygroundContext: Use lazy initialization for localStorage hydration

These changes maintain functionality while satisfying the strict
react-hooks/set-state-in-effect ESLint rule.

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2026-01-29 15:00:58 +00:00
parent 9db03083a0
commit ad435e4344
4 changed files with 59 additions and 61 deletions
-2
View File
@@ -9,8 +9,6 @@ const eslintConfig = defineConfig([
'notice/notice': 'off',
'react/forbid-elements': 'off',
'jsx-a11y/alt-text': 'off',
'@next/next/no-css-tags': 'off',
'react-hooks/set-state-in-effect': 'warn',
},
},
]);
@@ -46,7 +46,7 @@ const myTheme = createTheme({
});
export const CustomTheme = () => {
const [isClient, setIsClient] = useState(false);
const [isClient, setIsClient] = useState(() => typeof window !== 'undefined');
const [open, setOpen] = useState(true);
const [customTheme, setCustomTheme] = useState<string | undefined>(undefined);
const { selectedThemeName } = usePlayground();
@@ -73,7 +73,10 @@ export const CustomTheme = () => {
storedTheme = defaultTheme;
localStorage.setItem('customThemeCss', storedTheme);
}
setCustomTheme(storedTheme);
// Defer setState to avoid synchronous call in effect
requestAnimationFrame(() => {
setCustomTheme(storedTheme!);
});
updateStyleElement(storedTheme);
} else {
const styleElement = document.getElementById(
@@ -85,10 +88,6 @@ export const CustomTheme = () => {
}
}, [selectedThemeName]);
useEffect(() => {
setIsClient(true);
}, []);
const handleSave = () => {
if (customTheme) {
localStorage.setItem('customThemeCss', customTheme);
@@ -1,6 +1,6 @@
'use client';
import { useEffect, useState } from 'react';
import { useEffect, useState, useLayoutEffect } from 'react';
import { usePathname } from 'next/navigation';
import styles from './TableOfContents.module.css';
@@ -17,6 +17,29 @@ export function TableOfContents() {
const [indicatorHeight, setIndicatorHeight] = useState<number>(0);
const pathname = usePathname();
// Update indicator position when activeId changes
useLayoutEffect(() => {
if (!activeId) return;
// Use requestAnimationFrame to defer setState call
const rafId = requestAnimationFrame(() => {
const activeElement = document.querySelector(
`[data-toc-id="${activeId}"]`,
) as HTMLElement;
if (activeElement) {
const list = activeElement.closest('ul');
if (list) {
const listRect = list.getBoundingClientRect();
const elementRect = activeElement.getBoundingClientRect();
setIndicatorTop(elementRect.top - listRect.top);
setIndicatorHeight(elementRect.height);
}
}
});
return () => cancelAnimationFrame(rafId);
}, [activeId, headings]);
useEffect(() => {
// Extract all H2 and H3 headings from the document
const elements = Array.from(
@@ -29,18 +52,6 @@ export function TableOfContents() {
level: parseInt(element.tagName.substring(1)),
}));
setHeadings(headingData);
// Set initial active heading (first visible heading or first heading)
if (headingData.length > 0) {
const viewportTop = window.scrollY + 100; // offset for header
const visibleHeading = elements.find(element => {
const rect = element.getBoundingClientRect();
return rect.top + window.scrollY >= viewportTop - 200;
});
setActiveId(visibleHeading?.id || headingData[0].id);
}
// Set up IntersectionObserver to track visible headings
const observerOptions = {
rootMargin: '-80px 0px -80% 0px',
@@ -62,29 +73,26 @@ export function TableOfContents() {
elements.forEach(element => observer.observe(element));
// Initialize headings and active ID after observer is set up
requestAnimationFrame(() => {
setHeadings(headingData);
// Set initial active heading (first visible heading or first heading)
if (headingData.length > 0) {
const viewportTop = window.scrollY + 100; // offset for header
const visibleHeading = elements.find(element => {
const rect = element.getBoundingClientRect();
return rect.top + window.scrollY >= viewportTop - 200;
});
setActiveId(visibleHeading?.id || headingData[0].id);
}
});
return () => {
elements.forEach(element => observer.unobserve(element));
};
}, [pathname]);
// Update indicator position when activeId changes
useEffect(() => {
if (activeId) {
const activeElement = document.querySelector(
`[data-toc-id="${activeId}"]`,
) as HTMLElement;
if (activeElement) {
const list = activeElement.closest('ul');
if (list) {
const listRect = list.getBoundingClientRect();
const elementRect = activeElement.getBoundingClientRect();
setIndicatorTop(elementRect.top - listRect.top);
setIndicatorHeight(elementRect.height);
}
}
}
}, [activeId, headings]);
const handleClick = (id: string) => {
const element = document.getElementById(id);
if (element) {
+15 -22
View File
@@ -22,13 +22,13 @@ const PlaygroundContext = createContext<{
setSelectedThemeName: (themeName: ThemeName) => void;
}>({
selectedScreenSizes: [],
setSelectedScreenSizes: () => {},
setSelectedScreenSizes: () => { },
selectedComponents: [],
setSelectedComponents: () => {},
setSelectedComponents: () => { },
selectedTheme: new Set(['light']),
setSelectedTheme: () => {},
setSelectedTheme: () => { },
selectedThemeName: 'backstage',
setSelectedThemeName: () => {},
setSelectedThemeName: () => { },
});
// Create a provider component
@@ -40,37 +40,30 @@ export const PlaygroundProvider = ({ children }: { children: ReactNode }) => {
const [selectedComponents, setSelectedComponents] = useState<string[]>(
components.map(component => component.slug),
);
const [selectedTheme, setSelectedTheme] = useState<Set<Theme>>(
new Set(['light']),
);
const [selectedThemeName, setSelectedThemeName] =
useState<ThemeName>('backstage');
// Load saved theme from localStorage after hydration
useEffect(() => {
if (isBrowser) {
// Use lazy initialization to load from localStorage
const [selectedTheme, setSelectedTheme] = useState<Set<Theme>>(() => {
if (typeof window !== 'undefined') {
const savedThemeString = localStorage.getItem('theme-mode');
if (savedThemeString) {
// Parse the comma-separated string back into a Set
const themeArray = savedThemeString
.split(',')
.filter(Boolean) as Theme[];
setSelectedTheme(new Set(themeArray));
} else {
setSelectedTheme(new Set(['light']));
return new Set(themeArray);
}
}
}, [isBrowser]);
return new Set(['light']);
});
// Load saved theme name from localStorage after hydration
useEffect(() => {
if (isBrowser) {
const [selectedThemeName, setSelectedThemeName] = useState<ThemeName>(() => {
if (typeof window !== 'undefined') {
const savedThemeName = localStorage.getItem('theme-name') as ThemeName;
if (savedThemeName) {
setSelectedThemeName(savedThemeName);
return savedThemeName;
}
}
}, [isBrowser]);
return 'backstage';
});
useEffect(() => {
if (isBrowser) {