Fix React hook violations and optimize component behavior

Update form state management in AddEventModal and improve GoogleTab OAuth handling

Refactor EventListScreen to remove unused state calculation

Add missing dependencies in JourneyList useMemo hooks

Simplify events store initialization in EventsProvider
This commit is contained in:
2026-05-18 21:56:11 +02:00
parent b240d638ef
commit 9c1f6ebf7e
7 changed files with 48 additions and 47 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ const byPrefixAndName = { fas: { bars: faBars } };
type HeaderMenuProps = {
navigation: {
navigate: (screen: 'CalendarImport' | 'Settings') => void;
navigate: (_screen: 'CalendarImport' | 'Settings') => void;
};
};
@@ -61,7 +61,7 @@ function CalendarCheckbox({
}: {
calendar: SelectableCalendar;
selected: boolean;
onToggle: (id: string) => void;
onToggle: (_id: string) => void;
colors: ReturnType<typeof useColors>;
}) {
const badgeColor = BADGE_COLORS[calendar.accountType] ?? BADGE_COLORS.other;
@@ -178,13 +178,6 @@ export function EventListScreen({ navigation }: ScreenProps) {
const leaveCountdownLabel = leaveCountdown?.label;
const leaveByLabel = leaveBy ? formatTime(leaveBy) : null;
const trainLabel = selectedJourney?.trains.length ? selectedJourney.trains.join(', ') : 'Searching for train connection';
const status = leaveCountdown
? leaveCountdown.label === 'Now'
? 'Leave now'
: `Leave in ${leaveCountdown.label}`
: journeysLoading || destStation.loading
? 'Calculating departure time'
: 'No connection';
return (
<View style={styles.cardWrapper}>
+7 -3
View File
@@ -33,8 +33,12 @@ const AddEventModal: React.FC<AddEventModalProps> = ({ isOpen, onClose, editEven
const { addEvent, updateEvent } = useEventsStore();
// Populate fields when opening in edit mode
useEffect(() => {
// Track previous props to reset form fields when they change (avoids setState in useEffect)
const [prevEditEvent, setPrevEditEvent] = useState<Event | undefined>(editEvent);
const [prevIsOpen, setPrevIsOpen] = useState(isOpen);
if (editEvent !== prevEditEvent || isOpen !== prevIsOpen) {
setPrevEditEvent(editEvent);
setPrevIsOpen(isOpen);
if (editEvent) {
setTitle(editEvent.title);
setDestination(editEvent.destination);
@@ -47,7 +51,7 @@ const AddEventModal: React.FC<AddEventModalProps> = ({ isOpen, onClose, editEven
setEventTime("");
}
setSuccess(false);
}, [editEvent, isOpen]);
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
+34 -29
View File
@@ -24,9 +24,28 @@ type GoogleTabProps = {
};
const GoogleTab: React.FC<GoogleTabProps> = ({ onEventsLoaded, className = "" }) => {
const [status, setStatus] = useState<GoogleStatus>("loading");
// Read and clean OAuth return params synchronously at mount (avoids setState in effect)
const [oauthParams] = useState<{ error: string | null; connected: string | null }>(() => {
if (typeof window === "undefined") return { error: null, connected: null };
const params = new URLSearchParams(window.location.search);
const error = params.get("google_error");
const connected = params.get("google_connected");
if (error || connected) {
const clean = new URL(window.location.href);
clean.searchParams.delete("google_connected");
clean.searchParams.delete("google_error");
window.history.replaceState(null, "", clean.toString());
}
return { error, connected };
});
const [status, setStatus] = useState<GoogleStatus>(
oauthParams.error ? "not-connected" : "loading"
);
const [syncing, setSyncing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(
oauthParams.error ? friendlyOAuthError(oauthParams.error) : null
);
const [syncedCount, setSyncedCount] = useState<number | null>(null);
const checkStatus = useCallback(async () => {
@@ -45,33 +64,6 @@ const GoogleTab: React.FC<GoogleTabProps> = ({ onEventsLoaded, className = "" })
}
}, []);
useEffect(() => {
// Handle OAuth return params
const params = new URLSearchParams(window.location.search);
const connected = params.get("google_connected");
const oauthError = params.get("google_error");
if (connected || oauthError) {
const clean = new URL(window.location.href);
clean.searchParams.delete("google_connected");
clean.searchParams.delete("google_error");
window.history.replaceState(null, "", clean.toString());
}
if (oauthError) {
setError(friendlyOAuthError(oauthError));
setStatus("not-connected");
return;
}
checkStatus().then(() => {
if (connected) {
syncEvents();
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const syncEvents = useCallback(async () => {
setSyncing(true);
setError(null);
@@ -98,6 +90,19 @@ const GoogleTab: React.FC<GoogleTabProps> = ({ onEventsLoaded, className = "" })
}
}, [onEventsLoaded]);
useEffect(() => {
if (!oauthParams.error) {
// eslint-disable-next-line react-hooks/set-state-in-effect
checkStatus().then(() => {
if (oauthParams.connected) {
syncEvents();
}
});
}
// checkStatus and syncEvents are stable callbacks; oauthParams is a mount-time constant
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const disconnect = async () => {
await fetch("/api/auth/google/disconnect", { method: "POST" });
setStatus("not-connected");
+4 -1
View File
@@ -34,7 +34,10 @@ const JourneyList: React.FC<JourneyListProps> = ({
className = "",
}) => {
const [expanded, setExpanded] = useState(false);
const targetArrivalTime = new Date(eventTime.getTime() - arrivalBufferMinutes * 60000);
const targetArrivalTime = useMemo(
() => new Date(eventTime.getTime() - arrivalBufferMinutes * 60000),
[eventTime, arrivalBufferMinutes],
);
const walkDurationMs = walkDurationSeconds * 1000;
const rankedJourneys = useMemo(
() => rankJourneys(journeys, targetArrivalTime, walkDurationMs),
+1 -5
View File
@@ -45,13 +45,9 @@ const EventsContext = createContext<EventsContextType | undefined>(undefined);
* Persists events to localStorage on every change (skips initial hydration write).
*/
export function EventsProvider({ children }: { children: ReactNode }) {
const [events, setEventsState] = useState<Event[]>([]);
const [events, setEventsState] = useState<Event[]>(loadFromStorage);
const skipInitialWrite = useRef(true);
useEffect(() => {
setEventsState(loadFromStorage());
}, []);
useEffect(() => {
if (skipInitialWrite.current) {
skipInitialWrite.current = false;