Skip to content

refactor: replace whole-slice selectors with granular field selectors#5

Open
ashkanrdn wants to merge 1 commit intorefactor/redux-cleanupfrom
refactor/granular-selectors
Open

refactor: replace whole-slice selectors with granular field selectors#5
ashkanrdn wants to merge 1 commit intorefactor/redux-cleanupfrom
refactor/granular-selectors

Conversation

@ashkanrdn
Copy link
Owner

@ashkanrdn ashkanrdn commented Feb 21, 2026

Summary

  • Add lib/features/filters/selectors.ts with individual typed selectors for every FilterState field — components no longer re-render on unrelated state changes
  • MapStory: replace useSelector(state => state.filters) destructure with 8 granular selectors; remove unused RootState import
  • FilterSidebar: replace whole-slice destructure with 9 granular selectors
  • MetricsCards: replace whole-slice destructure with 3 granular selectors
  • CountyRank: migrate to selectSelectedMetric, selectIsPerCapita, selectSelectedCounty — removes last inline state.filters / state.map accesses

Test plan

  • npm run build passes with no TypeScript errors
  • Load /map — map renders counties with colors
  • FilterSidebar filters update the map
  • BarChart updates when metric changes
  • County click flies to county and highlights in ranking
  • Per capita toggle works
  • Check browser console for no runtime errors

🤖 Generated with Claude Code

Summary by Sourcery

Introduce granular typed selectors for the filters slice and update map-related components to use them for more targeted Redux subscriptions.

Enhancements:

  • Add dedicated selector module for individual FilterState fields.
  • Refactor MapStory, FilterSidebar, MetricsCards, and CountyRank to replace whole-slice state access with field-level selectors and shared map selectors.

- Add lib/features/filters/selectors.ts with individual selectors for every
  FilterState field (prevents re-renders on unrelated state changes)
- MapStory: replace useSelector(state => state.filters) destructure with 8 granular
  selectors; remove unused RootState import
- FilterSidebar: replace whole-slice destructure with 9 granular selectors
- MetricsCards: replace whole-slice destructure with 3 granular selectors
- CountyRank: migrate to selectSelectedMetric, selectIsPerCapita, selectSelectedCounty
  (removes last inline state.filters / state.map accesses)

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
@vercel
Copy link

vercel bot commented Feb 21, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
casi-2 Error Error Feb 21, 2026 6:58pm

@sourcery-ai
Copy link

sourcery-ai bot commented Feb 21, 2026

Reviewer's Guide

Refactors Redux state access to use new granular, typed selectors for the filters slice (and existing map selectors), reducing unnecessary re-renders and centralizing selector logic in a dedicated module.

Sequence diagram for granular selector-based re-rendering

sequenceDiagram
    actor User
    participant FiltersSidebar
    participant MetricsCards
    participant MapStory
    participant ReduxStore as Redux_Store
    participant FiltersSlice
    participant FiltersSelectors

    User->>FiltersSidebar: Adjust filter control
    FiltersSidebar->>ReduxStore: dispatch(setFilterAction)
    ReduxStore->>FiltersSlice: Apply setFilterAction
    FiltersSlice-->>ReduxStore: Update filteredData and activeFilters

    ReduxStore->>FiltersSelectors: selectFilteredData(state)
    FiltersSelectors-->>MapStory: filteredData
    ReduxStore->>FiltersSelectors: selectActiveFilters(state)
    FiltersSelectors-->>FiltersSidebar: activeFilters
    ReduxStore->>FiltersSelectors: selectCsvDataSources(state)
    FiltersSelectors-->>MetricsCards: csvDataSources

    MapStory->>MapStory: Re-render because filteredData changed
    FiltersSidebar->>FiltersSidebar: Re-render because activeFilters changed
    MetricsCards->>MetricsCards: Re-render only if csvDataSources changed

    Note over MapStory,MetricsCards: Each component re-renders only for the specific fields it selects instead of the entire filters slice
Loading

Class diagram for RootState, filters selectors, and consumers

classDiagram
    class RootState {
        FiltersState filters
        MapState map
    }

    class FiltersState {
        any filteredData
        any activeFilters
        string selectedMetric
        boolean isPerCapita
        string selectedDataSource
        any selectedCounties
        any yearRange
        any csvDataSources
        any filters
        any dataSourcesStatus
        any dataSourcesErrors
    }

    class MapState {
        any selectedCounty
        any rankedCounties
    }

    class FiltersSelectors {
        +selectFilteredData(state RootState) FiltersState.filteredData
        +selectActiveFilters(state RootState) FiltersState.activeFilters
        +selectSelectedMetric(state RootState) FiltersState.selectedMetric
        +selectIsPerCapita(state RootState) FiltersState.isPerCapita
        +selectSelectedDataSource(state RootState) FiltersState.selectedDataSource
        +selectSelectedCounties(state RootState) FiltersState.selectedCounties
        +selectYearRange(state RootState) FiltersState.yearRange
        +selectCsvDataSources(state RootState) FiltersState.csvDataSources
        +selectFilters(state RootState) FiltersState.filters
        +selectDataSourcesStatus(state RootState) FiltersState.dataSourcesStatus
        +selectDataSourcesErrors(state RootState) FiltersState.dataSourcesErrors
    }

    class MapSelectors {
        +selectRankedCounties(state RootState) MapState.rankedCounties
        +selectSelectedCounty(state RootState) MapState.selectedCounty
    }

    class MapStory {
        +useSelector(selectFilteredData)
        +useSelector(selectSelectedMetric)
        +useSelector(selectSelectedDataSource)
        +useSelector(selectIsPerCapita)
        +useSelector(selectSelectedCounties)
        +useSelector(selectCsvDataSources)
        +useSelector(selectActiveFilters)
        +useSelector(selectSelectedCounty)
    }

    class FiltersSidebar {
        +useSelector(selectFilters)
        +useSelector(selectActiveFilters)
        +useSelector(selectCsvDataSources)
        +useSelector(selectDataSourcesStatus)
        +useSelector(selectDataSourcesErrors)
        +useSelector(selectFilteredData)
        +useSelector(selectSelectedDataSource)
        +useSelector(selectYearRange)
        +useSelector(selectSelectedCounties)
    }

    class MetricsCards {
        +useSelector(selectCsvDataSources)
        +useSelector(selectDataSourcesStatus)
        +useSelector(selectActiveFilters)
        +useSelector(selectSelectedCounty)
    }

    class CountyRank {
        +useSelector(selectRankedCounties)
        +useSelector(selectSelectedMetric)
        +useSelector(selectIsPerCapita)
        +useSelector(selectSelectedCounty)
    }

    RootState --> FiltersState
    RootState --> MapState

    FiltersSelectors --> RootState
    MapSelectors --> RootState

    MapStory ..> FiltersSelectors
    MapStory ..> MapSelectors

    FiltersSidebar ..> FiltersSelectors

    MetricsCards ..> FiltersSelectors
    MetricsCards ..> MapSelectors

    CountyRank ..> FiltersSelectors
    CountyRank ..> MapSelectors
Loading

File-Level Changes

Change Details Files
Introduce dedicated typed selectors for the filters slice and use them across widgets instead of whole-slice selectors.
  • Add selectors.ts defining field-level selectors for all relevant FilterState properties, typed against RootState
  • Keep a single source of truth for filters-related selection logic, including derived collections like csvDataSources and status/error fields
lib/features/filters/selectors.ts
Update MapStory to consume granular selectors for filters and map state instead of destructuring entire slices.
  • Replace useSelector(state => state.filters) destructuring with individual useSelector calls using the new filters selectors
  • Switch selectedCounty selection to use the existing map selector instead of inline state.map access
  • Remove unused RootState import now that inline state typing is no longer needed
app/components/widgets/MapStory.tsx
Update FilterSidebar to rely on granular filters selectors, avoiding whole-slice reads.
  • Replace single filters slice selection and object destructuring with per-field useSelector calls from the new selectors module
  • Preserve selectedCounties default-to-empty-array behavior via nullish coalescing
  • Ensure all previously used filter-related fields (filters, activeFilters, csvDataSources, status, errors, etc.) are mapped to corresponding selectors
app/components/widgets/FilterSidebar.tsx
Update MetricsCards to use granular selectors for filters and map state and simplify store typings.
  • Replace whole filters slice selection with field-level selectors for csvDataSources, dataSourcesStatus, and activeFilters
  • Use map selectedCounty selector instead of inline state.map access
  • Drop now-unnecessary RootState import, retaining only AppDispatch typing
app/components/widgets/MetricsCards.tsx
Update CountyRank to use shared selectors for metric, per-capita flag, and selected county instead of inline state access.
  • Use selectSelectedMetric and selectIsPerCapita from the new filters selectors module
  • Use selectSelectedCounty from the existing map selectors module instead of accessing state.map directly
  • Leave ranked counties selection via selectRankedCounties unchanged while aligning other fields with the new selector pattern
app/components/widgets/CountyRank.tsx

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • Since multiple components need selectedCounties ?? [], consider baking the defaulting behavior into selectSelectedCounties (e.g., return an empty array when undefined) so that callers don’t have to remember to coalesce.
  • You might want to add a selectFiltersState = (state: RootState) => state.filters helper and build the field selectors off that to avoid repeating state.filters in every selector and keep things consistent if the slice key ever changes.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- Since multiple components need `selectedCounties ?? []`, consider baking the defaulting behavior into `selectSelectedCounties` (e.g., return an empty array when undefined) so that callers don’t have to remember to coalesce.
- You might want to add a `selectFiltersState = (state: RootState) => state.filters` helper and build the field selectors off that to avoid repeating `state.filters` in every selector and keep things consistent if the slice key ever changes.

## Individual Comments

### Comment 1
<location> `app/components/widgets/FilterSidebar.tsx:171` </location>
<code_context>
+    const filteredData       = useSelector(selectFilteredData);
+    const selectedDataSource = useSelector(selectSelectedDataSource);
+    const yearRange          = useSelector(selectYearRange);
+    const selectedCounties   = useSelector(selectSelectedCounties) ?? [];

     // Get current source's data, status, and error
</code_context>

<issue_to_address>
**suggestion:** The `?? []` defaulting for `selectedCounties` might be better centralized in the selector.

Since you’re defaulting `selectedCounties` to `[]` here, consider moving that fallback into `selectSelectedCounties` (e.g. `state.filters.selectedCounties ?? []`). This keeps the behavior consistent for all consumers and avoids repeating the same fallback in multiple components.

Suggested implementation:

```typescript
    const selectedCounties   = useSelector(selectSelectedCounties);

```

To fully implement your suggestion, the `selectSelectedCounties` selector should be updated to centralize the `[]` defaulting. In the selector definition file (for example, wherever `selectSelectedCounties` is defined, such as `app/store/selectors/filters.ts` or similar), update its implementation to return a non-null array:

<<<<<<< SEARCH
export const selectSelectedCounties = (state: RootState) => state.filters.selectedCounties;
=======
export const selectSelectedCounties = (state: RootState) =>
  state.filters.selectedCounties ?? [];
>>>>>>> REPLACE

You may also want to ensure the selector’s TypeScript type reflects that it always returns an array (e.g. `string[]` instead of `string[] | null | undefined`), and adjust any other selectors/components that were manually defaulting `selectedCounties` so they now rely on the updated selector behavior.
</issue_to_address>

### Comment 2
<location> `app/components/widgets/CountyRank.tsx:13` </location>
<code_context>
 import { formatMetricLabel } from '@/lib/utils/metricFormatters';

 export default function CountyRank() {
     const dispatch = useDispatch();
-    const counties = useSelector(selectRankedCounties);
-    const selectedMetric = useSelector((state: RootState) => state.filters.selectedMetric);
</code_context>

<issue_to_address>
**suggestion:** The untyped `useDispatch` here is inconsistent with other components that use a typed dispatch.

Components like `MapStory`, `FiltersSidebar`, and `MetricsCards` use `useDispatch<AppDispatch>()` for stronger typing. To keep type safety and consistency, please type this dispatch as well (or use a shared `useAppDispatch` hook if available).

Suggested implementation:

```typescript
import { formatMetricLabel } from '@/lib/utils/metricFormatters';
import type { AppDispatch } from '@/lib/store';

```

```typescript
export default function CountyRank() {
    const dispatch = useDispatch<AppDispatch>();

```

1. Ensure the `AppDispatch` import path matches the rest of the codebase. For example, if other components (e.g. `MapStory`, `FiltersSidebar`, `MetricsCards`) import it from a different module (such as `@/lib/redux/store` or similar), update the import path here to be identical.
2. If the project already has a `useAppDispatch` hook (e.g. in `@/lib/hooks`), you may prefer to:
   - Import `useAppDispatch` instead of `useDispatch`.
   - Replace `const dispatch = useDispatch<AppDispatch>();` with `const dispatch = useAppDispatch();`.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

const filteredData = useSelector(selectFilteredData);
const selectedDataSource = useSelector(selectSelectedDataSource);
const yearRange = useSelector(selectYearRange);
const selectedCounties = useSelector(selectSelectedCounties) ?? [];
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: The ?? [] defaulting for selectedCounties might be better centralized in the selector.

Since you’re defaulting selectedCounties to [] here, consider moving that fallback into selectSelectedCounties (e.g. state.filters.selectedCounties ?? []). This keeps the behavior consistent for all consumers and avoids repeating the same fallback in multiple components.

Suggested implementation:

    const selectedCounties   = useSelector(selectSelectedCounties);

To fully implement your suggestion, the selectSelectedCounties selector should be updated to centralize the [] defaulting. In the selector definition file (for example, wherever selectSelectedCounties is defined, such as app/store/selectors/filters.ts or similar), update its implementation to return a non-null array:

<<<<<<< SEARCH
export const selectSelectedCounties = (state: RootState) => state.filters.selectedCounties;

export const selectSelectedCounties = (state: RootState) =>
state.filters.selectedCounties ?? [];

REPLACE

You may also want to ensure the selector’s TypeScript type reflects that it always returns an array (e.g. string[] instead of string[] | null | undefined), and adjust any other selectors/components that were manually defaulting selectedCounties so they now rely on the updated selector behavior.

import { formatMetricLabel } from '@/lib/utils/metricFormatters';

export default function CountyRank() {
const dispatch = useDispatch();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: The untyped useDispatch here is inconsistent with other components that use a typed dispatch.

Components like MapStory, FiltersSidebar, and MetricsCards use useDispatch<AppDispatch>() for stronger typing. To keep type safety and consistency, please type this dispatch as well (or use a shared useAppDispatch hook if available).

Suggested implementation:

import { formatMetricLabel } from '@/lib/utils/metricFormatters';
import type { AppDispatch } from '@/lib/store';
export default function CountyRank() {
    const dispatch = useDispatch<AppDispatch>();
  1. Ensure the AppDispatch import path matches the rest of the codebase. For example, if other components (e.g. MapStory, FiltersSidebar, MetricsCards) import it from a different module (such as @/lib/redux/store or similar), update the import path here to be identical.
  2. If the project already has a useAppDispatch hook (e.g. in @/lib/hooks), you may prefer to:
    • Import useAppDispatch instead of useDispatch.
    • Replace const dispatch = useDispatch<AppDispatch>(); with const dispatch = useAppDispatch();.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant