Skip to content

React and JSX

These guidelines cover React and JSX patterns used in SlideSpeak. They focus on avoiding common pitfalls, using React effectively, and following patterns that make code easier to maintain and debug.

useEffect is an escape hatch from React’s paradigm. Most code doesn’t need Effects. State updates should occur event-based, not through Effects.

When you use useEffect unnecessarily, you create extra render cycles, make code harder to understand because it no longer reads from top to bottom, and introduce potential bugs like race conditions or stale closures.

React already re-renders when props or state change. Calculating derived values during render is more efficient than using Effects because it avoids unnecessary render cycles.

❌ Bad: Using Effect to calculate derived state

const [isSelected, setIsSelected] = useState(false);
useEffect(() => {
setIsSelected(selectedSlideId === id);
}, [selectedSlideId, id]);

This causes two renders: first with stale isSelected, then immediately again with the updated value.

✅ Good: Calculate during render

const isSelected = selectedSlideId === id;
const isFocused = isSelected && slideTrayIsFocused;

If you need to reset local state when props change, use the key prop to reset the entire component instead of syncing in an Effect.

❌ Bad: Syncing props to state with Effect

const [localTitle, setLocalTitle] = useState(title);
useEffect(() => {
setLocalTitle(title);
}, [title]);

✅ Good: Use props directly, or use key to reset component

const EditableSlide = ({ title }: { title: string }) => {
const [localTitle, setLocalTitle] = useState(title);
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
setLocalTitle(e.target.value);
};
return <input value={localTitle} onChange={handleChange} />;
};
<EditableSlide title={title} key={slideId} />

When slideId changes, React will remount the component with fresh state.

Event handlers already know exactly what action the user took. Effects run after render and don’t have direct access to the user’s intent.

❌ Bad: Event logic in Effect

useEffect(() => {
if (product.isInCart) {
showNotification(`Added ${product.name} to cart!`);
}
}, [product]);

✅ Good: Event logic in handler

const handleClick = () => {
EditorActions.updateSelectedSlideId(id);
EditorActions.updateSlideTrayIsFocused(true);
};
<button onClick={handleClick}>Select</button>

Use React Query instead of Effects for data fetching. React Query handles caching, race conditions, loading states, error handling, and refetching automatically.

❌ Bad: Fetching data with Effect

const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
let ignore = false;
fetch(`/api/user/${userId}`)
.then(res => res.json())
.then(json => {
if (!ignore) {
setData(json);
setIsLoading(false);
}
});
return () => {
ignore = true;
};
}, [userId]);

You have to manually handle cleanup, loading states, errors, and caching.

✅ Good: Use React Query

const { data: user, refetch } = useQuery({
queryKey: [USER_INFO_QUERY_KEY],
queryFn: () => getUserInfo(),
staleTime: 1000 * 60,
});

React Query automatically handles:

  • Caching
  • Loading and error states
  • Race condition prevention

For conditional fetching:

const query = useQuery({
queryKey: ['document', 'selected', resolvedId],
queryFn: async () => {
if (!resolvedId) return null;
return getDocumentForUser(resolvedId);
},
enabled: Boolean(resolvedId),
staleTime: Infinity,
gcTime: Infinity,
});

Only use useEffect for:

  • Component mount logic (no dependency array) - Initialize something once when component mounts
  • Syncing with external systems - Connect to timers, subscriptions, browser APIs, or non-React widgets

✅ Good: Syncing with external system (timer)

useEffect(() => {
const interval = setInterval(() => {
updateTime();
}, 1000);
return () => clearInterval(interval);
}, []);

✅ Good: Component mount initialization

useEffect(() => {
initializeAnalytics();
}, []);

Always clean up subscriptions, timers, or event listeners in the cleanup function to prevent memory leaks.

Always use const syntax with arrow functions for component definitions. Use FC<> (Function Component) type from React to type your components.

import { FC } from 'react';

Define props as a type and use FC<PropsType>:

import { FC } from 'react';
type ActionBannerProps = {
title: string;
description: string;
actionText: string;
onAction: () => void;
icon?: React.ReactNode;
};
export const ActionBanner: FC<ActionBannerProps> = ({
title,
description,
actionText,
onAction,
icon,
}) => {
return (
<div className="flex items-center gap-3">
<div>{title}</div>
<Button onClick={onAction}>{actionText}</Button>
</div>
);
};

For components without props, use FC without type parameters:

import { FC } from 'react';
export const LanguageSelector: FC = () => {
const { language } = useOutlinePresentationSettings();
return (
<div>
<SearchLanguageSelect value={language} />
</div>
);
};

Extending HTML Attributes for Reusable Components

Section titled “Extending HTML Attributes for Reusable Components”

When creating reusable UI components that will be used in multiple places, extend HTMLAttributes to allow consumers to pass through standard HTML props like onClick, data-* attributes, aria-* attributes, and custom className.

Use intersection types with HTMLAttributes:

import { FC, HTMLAttributes } from 'react';
import { cn } from '@/utils/cn';
type OnboardingMessageProps = {
message: string;
description?: string;
buttonText: string;
buttonAction: () => void;
} & HTMLAttributes<HTMLDivElement>;
export const OnboardingMessage: FC<OnboardingMessageProps> = ({
message,
description,
buttonText,
buttonAction,
className,
...props
}) => (
<div
className={cn(
'flex w-[360px] max-w-[90vw] flex-col items-center gap-6 rounded-2xl bg-white p-6',
className,
)}
{...props}
>
<div>{message}</div>
<Button onClick={buttonAction}>{buttonText}</Button>
</div>
);

Key points:

  • Extract className separately to merge with cn()
  • Spread ...props onto the root div to pass through all HTML attributes
  • This allows consumers to add onClick, data-* attributes, aria-* attributes, etc.

Always use cn() (from @/utils/cn) instead of string concatenation for className. It combines clsx for conditional classes and tailwind-merge to intelligently resolve Tailwind class conflicts.

Benefits:

  • Conditional classes - Easily add classes based on conditions
  • Merge props - Automatically merges className prop with base classes
  • Conflict resolution - Resolves Tailwind conflicts (e.g., p-4 and p-2 → keeps p-2)

❌ Bad: String concatenation

<div className={`flex w-full gap-3 ${isUser ? 'flex-row-reverse' : 'flex-row'} ${className || ''}`}>

✅ Good: Using cn()

import { cn } from '@/utils/cn';
<div
className={cn(
'flex w-full gap-3 py-2',
isUser ? 'flex-row-reverse justify-start' : 'flex-row justify-start',
className,
)}
>

Example: Conditional classes

<div
className={cn(
'relative border rounded-lg overflow-hidden border-gray-200',
isSelected && 'outline outline-[3px] outline-blue-600',
)}
>

Example: Conditional values

<div
className={cn(
'rounded-xl p-4',
isUser ? 'bg-blue-100 outline-blue-200' : 'bg-white outline-blue-100',
bubbleClassName,
)}
>

When merging className prop, always put it last so consumers can override base styles:

className={cn(
'base-classes-here',
conditionalClasses,
className, // Always last to allow overrides
)}